Initial Commit
This commit is contained in:
commit
b3acaef775
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
public/css/output.css
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsprojn
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Already present entries...
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
public/css/output.css
|
||||||
101
README.md
Normal file
101
README.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# RegisterFrontend
|
||||||
|
|
||||||
|
This is a modern React + Vite + Tailwind CSS frontend for a registration and dashboard system. It features authentication, user onboarding, admin management, and responsive UI components, structured for scalability and maintainability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
- **src/features/**
|
||||||
|
Feature-based folders (e.g., `login`, `dashboard`, `admin`) each with their own `components/`, `pages/`, and (optionally) `hooks/`, `api/`, and `__tests__/`.
|
||||||
|
- **src/shared/**
|
||||||
|
Reusable components, hooks, utilities, and store logic shared across features.
|
||||||
|
- **src/auth/AuthWrapper.jsx**
|
||||||
|
Handles authentication, token refresh, and protected route logic.
|
||||||
|
- **src/store/**
|
||||||
|
Zustand stores for global state (e.g., authentication).
|
||||||
|
- **src/pages/**
|
||||||
|
Top-level route pages (may be migrated into features as the project evolves).
|
||||||
|
- **tailwind.config.js**
|
||||||
|
Tailwind CSS configuration.
|
||||||
|
- **vite.config.js**
|
||||||
|
Vite build and dev server configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started (Local Development)
|
||||||
|
|
||||||
|
### 1. **Clone the Repository**
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd REGISTERFRONTEND
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Environment Variables**
|
||||||
|
Create a `.env` file in the root directory. Example:
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
|
VITE_CENTRAL_SERVER_ENDPOINT=http://localhost:3001
|
||||||
|
```
|
||||||
|
Adjust these URLs to match your backend API endpoints.
|
||||||
|
|
||||||
|
### 4. **Run the Development Server**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
The app will be available at [http://localhost:5173](http://localhost:5173) (or the port shown in your terminal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Key Features
|
||||||
|
|
||||||
|
- **Authentication:**
|
||||||
|
Login, token refresh, and protected routes via `AuthWrapper`.
|
||||||
|
- **Feature-based Structure:**
|
||||||
|
Each major feature (login, dashboard, admin, etc.) is isolated for scalability.
|
||||||
|
- **Responsive Design:**
|
||||||
|
Built with Tailwind CSS and responsive utility classes.
|
||||||
|
- **State Management:**
|
||||||
|
Uses Zustand for global state (auth, user).
|
||||||
|
- **API Integration:**
|
||||||
|
Fetches data from a backend API, with credentials support for secure cookies.
|
||||||
|
- **Referral Link Generation:**
|
||||||
|
Referral links are generated without specifying a registration type. The type (personal/company) is now selected by the user during the registration process, not during referral link creation.
|
||||||
|
- **Registration Flow:**
|
||||||
|
During registration, users select their registration type (personal or company) via a toggle/slider. This selection is sent to the backend as part of the registration request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧑💻 Development Notes
|
||||||
|
|
||||||
|
- **Mobile Support:**
|
||||||
|
The app is being refactored for improved mobile responsiveness. Test your changes on various screen sizes.
|
||||||
|
- **Token Handling:**
|
||||||
|
Access tokens are stored in Zustand and sessionStorage; refresh logic is handled in `AuthWrapper.jsx`.
|
||||||
|
- **Custom Hooks & API Utilities:**
|
||||||
|
As the project grows, move repeated logic into `hooks/` and `api/` folders within each feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repo and create a feature branch.
|
||||||
|
2. Follow the existing folder structure and naming conventions.
|
||||||
|
3. Test your changes locally before submitting a pull request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is for internal use. Please contact the maintainer for licensing details.
|
||||||
|
|
||||||
|
---
|
||||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/img/profit_planet_favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Profit-Planet</title>
|
||||||
|
<link rel="stylesheet" href="/css/index.css">
|
||||||
|
<link rel="stylesheet" href="/css/fallback/register.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7977
package-lock.json
generated
Normal file
7977
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "registerhub",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-css": "npx @tailwindcss/cli -i ./public/css/index.css -o ./public/css/output.css"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"country-flag-icons": "^1.5.19",
|
||||||
|
"country-select-js": "^2.1.0",
|
||||||
|
"i18next": "^25.3.2",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"intl-tel-input": "^25.5.2",
|
||||||
|
"pdfjs-dist": "^5.4.54",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-preset-env": "^10.3.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"react-i18next": "^15.6.1",
|
||||||
|
"react-pdf": "^10.1.0",
|
||||||
|
"react-phone-number-input": "^3.4.12",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"yup": "^1.7.0",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@tailwindcss/cli": "^4.0.0",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"eslint": "^9.30.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
postcss.config.mjs
Normal file
13
postcss.config.mjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// filepath: c:\Users\alexa\Desktop\CODE\COMPLETE REDO\REGISTERFRONTEND\postcss.config.mjs
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
autoprefixer: {},
|
||||||
|
"postcss-preset-env": {
|
||||||
|
stage: 3,
|
||||||
|
features: {
|
||||||
|
"nesting-rules": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
179
public/css/fallback/register.css
Normal file
179
public/css/fallback/register.css
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/* Basic button fallbacks (older browsers / when Tailwind v4 utilities fail) */
|
||||||
|
.fallback-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn--primary {
|
||||||
|
background: #1d4ed8; /* blue-700 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn--primary:hover,
|
||||||
|
.fallback-btn--primary:focus {
|
||||||
|
background: #1e40af; /* blue-800 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn--secondary {
|
||||||
|
background: #1f2937; /* gray-800 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn--secondary:hover,
|
||||||
|
.fallback-btn--secondary:focus {
|
||||||
|
background: #111827; /* gray-900 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn:disabled,
|
||||||
|
.fallback-btn[aria-disabled="true"] {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn--auto {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.fallback-btn--auto {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner alignment fallback */
|
||||||
|
.fallback-btn svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Enhanced slider (mode toggle) fallbacks --- */
|
||||||
|
.register-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid #1d4ed8;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f9fafb;
|
||||||
|
position: relative;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto 1.25rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-mode-toggle button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
background: #e5e7eb !important; /* slate-200 */
|
||||||
|
color: #1e3a8a !important; /* blue-800 */
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
opacity: 1 !important;
|
||||||
|
border-right: 1px solid #1d4ed8;
|
||||||
|
/* --- Smooth transition for background, color, box-shadow, border --- */
|
||||||
|
transition:
|
||||||
|
background 0.35s cubic-bezier(.4,0,.2,1),
|
||||||
|
color 0.35s cubic-bezier(.4,0,.2,1),
|
||||||
|
box-shadow 0.35s cubic-bezier(.4,0,.2,1),
|
||||||
|
border 0.35s cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-mode-toggle button:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-mode-toggle button.active,
|
||||||
|
.register-mode-toggle button[aria-selected="true"] {
|
||||||
|
background: #1d4ed8 !important; /* blue-700 */
|
||||||
|
color: #fff !important;
|
||||||
|
/* Add a subtle shadow for depth */
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(30,64,175,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-mode-toggle button.active::after,
|
||||||
|
.register-mode-toggle button[aria-selected="true"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid #1d4ed8;
|
||||||
|
border-radius: 0; /* <-- changed from 0.75rem to 0 */
|
||||||
|
pointer-events: none;
|
||||||
|
/* Animate border appearance */
|
||||||
|
transition: border 0.35s cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provide a minimal focus style for accessibility */
|
||||||
|
.register-mode-toggle button:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px #bfdbfe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover:hover) {
|
||||||
|
.register-mode-toggle button:hover:not(.active) {
|
||||||
|
background: #cbd5e1 !important; /* slate-300 */
|
||||||
|
color: #1e3a8a !important;
|
||||||
|
/* Slight shadow on hover for feedback */
|
||||||
|
box-shadow: 0 1px 6px 0 rgba(30,64,175,0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graceful degradation for very old browsers */
|
||||||
|
@supports not (gap: 1rem) {
|
||||||
|
.register-mode-toggle button {
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
.register-mode-toggle button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast / prefers-reduced-transparency environments */
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
.register-mode-toggle button.active {
|
||||||
|
background: #003caa !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When no blur support we already ensure readable background elsewhere */
|
||||||
|
|
||||||
|
/* --- Safari-specific fallback for buttons and toggle --- */
|
||||||
|
@media not all and (min-resolution:.001dpcm) {
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.fallback-btn,
|
||||||
|
.register-mode-toggle button {
|
||||||
|
/* Safari sometimes ignores border-radius on buttons */
|
||||||
|
border-radius: 8px !important;
|
||||||
|
-webkit-border-radius: 8px !important;
|
||||||
|
/* Safari ignores some box-shadow, so add fallback */
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(30,64,175,0.08);
|
||||||
|
-webkit-box-shadow: 0 2px 8px 0 rgba(30,64,175,0.08);
|
||||||
|
}
|
||||||
|
.register-mode-toggle {
|
||||||
|
/* Safari fallback for flex gap */
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Legacy browser fallback for border-radius and appearance --- */
|
||||||
|
.fallback-btn,
|
||||||
|
.register-mode-toggle button {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
/* Fallback for very old browsers */
|
||||||
|
-webkit-border-radius: 0.5rem;
|
||||||
|
-moz-border-radius: 0.5rem;
|
||||||
|
/* Remove default button appearance */
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
1023
public/img/profit_planet_favicon.svg
Normal file
1023
public/img/profit_planet_favicon.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 66 KiB |
BIN
public/img/profitplanet_logo.png
Normal file
BIN
public/img/profitplanet_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
53
public/img/profitplanet_logo.svg
Normal file
53
public/img/profitplanet_logo.svg
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="2000.000000pt" height="212.000000pt" viewBox="0 0 2000.000000 212.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,212.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M4556 2090 c-401 -63 -639 -292 -731 -705 -22 -95 -31 -395 -16 -507
|
||||||
|
19 -142 49 -247 106 -364 105 -218 285 -361 531 -424 189 -49 458 -49 649 0
|
||||||
|
102 25 243 95 320 157 162 132 267 341 306 611 15 111 6 441 -16 537 -84 372
|
||||||
|
-276 584 -610 671 -87 23 -124 27 -290 30 -104 1 -217 -1 -249 -6z m329 -444
|
||||||
|
c99 -26 162 -78 209 -174 103 -206 96 -639 -14 -812 -41 -64 -76 -95 -145
|
||||||
|
-127 -53 -24 -72 -28 -160 -28 -127 0 -186 19 -258 85 -89 81 -131 190 -147
|
||||||
|
383 -10 122 1 287 25 382 42 161 133 264 262 294 58 14 172 12 228 -3z"/>
|
||||||
|
<path d="M13464 1089 c-214 -546 -391 -996 -392 -1001 -2 -5 28 -8 69 -6 l73
|
||||||
|
3 136 345 135 345 421 3 421 2 134 -350 134 -350 73 0 c68 0 73 2 67 19 -3 10
|
||||||
|
-174 459 -379 997 l-374 979 -64 3 -65 3 -389 -992z m477 754 c18 -55 309
|
||||||
|
-852 334 -915 7 -17 -15 -18 -364 -18 -204 0 -371 2 -371 5 0 3 64 171 141
|
||||||
|
373 116 301 175 461 224 610 8 22 13 13 36 -55z"/>
|
||||||
|
<path d="M220 1076 l0 -996 270 0 270 0 0 330 0 330 118 0 c421 0 672 157 758
|
||||||
|
475 25 92 30 297 9 389 -50 227 -199 373 -445 437 -80 21 -110 23 -532 26
|
||||||
|
l-448 4 0 -995z m750 553 c104 -24 154 -101 148 -229 -3 -68 -7 -84 -33 -121
|
||||||
|
-47 -67 -107 -93 -227 -97 l-98 -4 0 231 0 231 83 0 c45 0 102 -5 127 -11z"/>
|
||||||
|
<path d="M1980 1075 l0 -995 270 0 270 0 0 360 0 360 69 0 69 0 209 -357 209
|
||||||
|
-358 306 -3 306 -2 -272 407 c-150 225 -280 420 -289 434 l-17 25 70 48 c120
|
||||||
|
82 199 186 241 316 27 85 30 247 6 339 -62 232 -257 370 -580 411 -45 5 -258
|
||||||
|
10 -474 10 l-393 0 0 -995z m786 570 c54 -16 106 -62 123 -108 6 -16 11 -54
|
||||||
|
11 -86 0 -161 -80 -230 -277 -239 l-103 -5 0 227 0 226 98 0 c58 0 119 -6 148
|
||||||
|
-15z"/>
|
||||||
|
<path d="M6090 1075 l0 -995 265 0 265 0 2 373 3 372 298 3 297 2 0 215 0 215
|
||||||
|
-300 0 -300 0 0 190 0 190 325 0 325 0 0 215 0 215 -590 0 -590 0 0 -995z"/>
|
||||||
|
<path d="M7600 1075 l0 -995 270 0 270 0 0 995 0 995 -270 0 -270 0 0 -995z"/>
|
||||||
|
<path d="M8430 1850 l0 -220 240 0 240 0 0 -775 0 -775 270 0 270 0 0 775 0
|
||||||
|
775 243 2 242 3 3 218 2 217 -755 0 -755 0 0 -220z"/>
|
||||||
|
<path d="M10290 1075 l0 -995 70 0 70 0 0 409 0 408 243 6 c163 4 265 11 312
|
||||||
|
21 274 62 422 188 480 411 21 79 21 270 1 347 -45 169 -149 279 -323 339 -111
|
||||||
|
38 -229 49 -550 49 l-303 0 0 -995z m710 850 c237 -55 340 -184 340 -425 0
|
||||||
|
-239 -101 -373 -332 -441 -64 -19 -104 -22 -325 -26 l-253 -5 0 462 0 462 248
|
||||||
|
-4 c187 -4 265 -9 322 -23z"/>
|
||||||
|
<path d="M11920 1075 l0 -995 545 0 545 0 0 65 0 65 -475 0 -475 0 0 930 0
|
||||||
|
930 -70 0 -70 0 0 -995z"/>
|
||||||
|
<path d="M15030 1075 l0 -995 66 0 66 0 -4 880 c-1 483 0 876 3 872 3 -4 269
|
||||||
|
-400 590 -879 l583 -873 73 0 73 0 0 995 0 995 -71 0 -71 0 3 -874 c2 -481 2
|
||||||
|
-873 -1 -871 -3 2 -267 395 -585 874 l-580 870 -72 1 -73 0 0 -995z"/>
|
||||||
|
<path d="M17040 1075 l0 -995 545 0 545 0 0 65 0 65 -475 0 -475 0 0 425 0
|
||||||
|
425 445 0 445 0 0 65 0 65 -445 0 -445 0 0 380 0 380 475 0 475 0 0 60 0 60
|
||||||
|
-545 0 -545 0 0 -995z"/>
|
||||||
|
<path d="M18310 2010 l0 -60 325 0 325 0 0 -935 0 -935 70 0 70 0 2 933 3 932
|
||||||
|
323 3 322 2 0 60 0 60 -720 0 -720 0 0 -60z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
15
public/index.html
Normal file
15
public/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your App Title</title>
|
||||||
|
<link rel="stylesheet" href="/css/fallback/register.css" />
|
||||||
|
<!-- Add other head elements here -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Your app's root element -->
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- Add your scripts here -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29546
public/pdf/pdf.mjs
Normal file
29546
public/pdf/pdf.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1
public/pdf/pdf.mjs.map
Normal file
1
public/pdf/pdf.mjs.map
Normal file
File diff suppressed because one or more lines are too long
4244
public/pdf/pdf.sandbox.mjs
Normal file
4244
public/pdf/pdf.sandbox.mjs
Normal file
File diff suppressed because one or more lines are too long
1
public/pdf/pdf.sandbox.mjs.map
Normal file
1
public/pdf/pdf.sandbox.mjs.map
Normal file
File diff suppressed because one or more lines are too long
64492
public/pdf/pdf.worker.mjs
Normal file
64492
public/pdf/pdf.worker.mjs
Normal file
File diff suppressed because one or more lines are too long
1
public/pdf/pdf.worker.mjs.map
Normal file
1
public/pdf/pdf.worker.mjs.map
Normal file
File diff suppressed because one or more lines are too long
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
0
scripts/updateInventory.js
Normal file
0
scripts/updateInventory.js
Normal file
42
src/App.css
Normal file
42
src/App.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
36
src/App.jsx
Normal file
36
src/App.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import GlobalAnimatedBackground from './background/GlobalAnimatedBackground'
|
||||||
|
import AppRouter from './AppRouter'
|
||||||
|
import ToastComponent from './features/toast/ToastComponent'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { log } from './utils/logger' // import logger
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// Added debug env dump
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const env = import.meta?.env || {}
|
||||||
|
log('[App] import.meta.env keys:', Object.keys(env))
|
||||||
|
log('[App] VITE_API_BASE_URL:', env.VITE_API_BASE_URL)
|
||||||
|
log('[App] globalThis.VITE_API_BASE_URL:', globalThis.VITE_API_BASE_URL)
|
||||||
|
log('[App] globalThis.__ENV__:', globalThis.__ENV__)
|
||||||
|
const meta = document.querySelector('meta[name="vite-api-base-url"]')?.getAttribute('content')
|
||||||
|
if (meta) log('[App] meta[vite-api-base-url]:', meta)
|
||||||
|
if (!env.VITE_API_BASE_URL) {
|
||||||
|
log('[App] WARNING: VITE_API_BASE_URL is undefined! API calls may fail.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('[App] env debug failed', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<ToastComponent /> {/* Only here, outside AuthWrapper */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
<AppRouter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
405
src/AppRouter.jsx
Normal file
405
src/AppRouter.jsx
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import Register from './features/register/pages/Register.jsx';
|
||||||
|
import Login from './features/login/pages/Login.jsx';
|
||||||
|
import Dashboard from './features/dashboard/pages/Dashboard.jsx';
|
||||||
|
import UploadID from "./features/quickactions/uploadID/pages/UploadID.jsx";
|
||||||
|
import VerifyEmail from "./features/quickactions/verifyEmail/pages/VerifyEmail.jsx";
|
||||||
|
import PersonalCompleteProfile from "./features/quickactions/completeProfile/pages/PersonalCompleteProfile.jsx";
|
||||||
|
import CompanyCompleteProfile from "./features/quickactions/completeProfile/pages/CompanyCompleteProfile.jsx";
|
||||||
|
import SignContract from "./features/quickactions/contracts/pages/SignContract.jsx";
|
||||||
|
import ReferralManagement from "./features/referralManagement/pages/ReferralManagement.jsx";
|
||||||
|
import UserManagement from "./features/admin/userManagement/pages/UserManagement.jsx";
|
||||||
|
import AdminDashboard from "./features/admin/adminDashboard/pages/AdminDashboard.jsx";
|
||||||
|
import CompanyAuthMiddleware from './auth/CompanyAuthMiddleware.jsx';
|
||||||
|
import PersonalAuthMiddleware from './auth/PersonalAuthMiddleware.jsx';
|
||||||
|
import AdminAuthMiddleware from './auth/AdminAuthMiddleware.jsx';
|
||||||
|
import PermissionManagement from "./features/admin/permissionManagement/pages/PermissionManagement.jsx";
|
||||||
|
import useAuthStore from "./store/authStore";
|
||||||
|
import { showToast } from "./features/toast/toastUtils.js";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import AdminUserListView from "./features/admin/userManagement/components/AdminUserListView.jsx";
|
||||||
|
import UserProfilePage from "./features/profile/pages/UserProfilePage.jsx";
|
||||||
|
import VerifyUserQueue from "./features/admin/verifyUser/pages/VerifyUserQueue.jsx";
|
||||||
|
import NotFoundPage from "./features/error/pages/404.jsx";
|
||||||
|
import VerifyUser from "./features/admin/verifyUser/pages/VerifyUser.jsx";
|
||||||
|
import AdminUserProfilePage from "./features/admin/userManagement/pages/AdminUserProfilePage.jsx";
|
||||||
|
import PasswordReset from './features/password-reset/pages/PasswordReset.jsx';
|
||||||
|
import PasswordResetSetNewPassword from './features/password-reset/pages/PasswordResetSetNewPassword.jsx';
|
||||||
|
import ContractDashboard from "./features/admin/contractDashboard/pages/contractDashboard.jsx";
|
||||||
|
import { log } from "./utils/logger";
|
||||||
|
import AuthWrapper from './auth/AuthWrapper'
|
||||||
|
|
||||||
|
|
||||||
|
function AppRouter() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route path="/" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/password-reset" element={<PasswordReset />} />
|
||||||
|
<Route path="/password-reset-set" element={<PasswordResetSetNewPassword />} />
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<Dashboard />
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Personal Routes */}
|
||||||
|
<Route
|
||||||
|
path="/personal-verify-email"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<PersonalAuthMiddleware>
|
||||||
|
<VerifyEmail />
|
||||||
|
</PersonalAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/personal-id-upload"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<PersonalAuthMiddleware>
|
||||||
|
<UploadID />
|
||||||
|
</PersonalAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/personal-complete-profile"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<PersonalAuthMiddleware>
|
||||||
|
<PersonalCompleteProfile />
|
||||||
|
</PersonalAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/personal-sign-contract"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<PersonalAuthMiddleware>
|
||||||
|
<SignContractGuard>
|
||||||
|
<SignContract />
|
||||||
|
</SignContractGuard>
|
||||||
|
</PersonalAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Company Routes */}
|
||||||
|
<Route
|
||||||
|
path="/company-verify-email"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<CompanyAuthMiddleware>
|
||||||
|
<VerifyEmail />
|
||||||
|
</CompanyAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/company-id-upload"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<CompanyAuthMiddleware>
|
||||||
|
<UploadID />
|
||||||
|
</CompanyAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/company-complete-profile"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<CompanyAuthMiddleware>
|
||||||
|
<CompanyCompleteProfile />
|
||||||
|
</CompanyAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/company-sign-contract"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<CompanyAuthMiddleware>
|
||||||
|
<SignContractGuard>
|
||||||
|
<SignContract />
|
||||||
|
</SignContractGuard>
|
||||||
|
</CompanyAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/dashboard"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<AdminDashboard />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/user-management"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<UserManagement />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/permission-management"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<PermissionManagement />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/verify-users"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<VerifyUserQueue />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/user-management/view/:id"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<AdminUserListView />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/user-management/verify/:id"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<VerifyUser />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/user-management/profile/:id"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<AdminUserProfilePage />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/contract-dashboard"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminAuthMiddleware>
|
||||||
|
<ContractDashboard />
|
||||||
|
</AdminAuthMiddleware>
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Routes */}
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<ProfileRedirect />
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/personal/profile"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<UserProfilePage />
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/company/profile"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<UserProfilePage />
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Referral Management Route - Permission Protected */}
|
||||||
|
<Route
|
||||||
|
path="/referral-management"
|
||||||
|
element={
|
||||||
|
<AuthWrapper>
|
||||||
|
<ReferralManagementProtected />
|
||||||
|
</AuthWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 404 Catch-all Route */}
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppRouter;
|
||||||
|
|
||||||
|
// Profile redirect logic
|
||||||
|
function ProfileRedirect() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user?.userType === "company") {
|
||||||
|
navigate("/company/profile", { replace: true });
|
||||||
|
} else {
|
||||||
|
navigate("/personal/profile", { replace: true });
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Permission-protected wrapper for ReferralManagement
|
||||||
|
function ReferralManagementProtected() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const hasPermission =
|
||||||
|
user &&
|
||||||
|
(
|
||||||
|
user.role === "admin" ||
|
||||||
|
user.role === "super_admin" ||
|
||||||
|
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
|
||||||
|
);
|
||||||
|
if (!hasPermission) {
|
||||||
|
showToast({ type: "error", message: "You do not have permission to access Referral Management." });
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
// Only render if user is allowed
|
||||||
|
const hasPermission =
|
||||||
|
user &&
|
||||||
|
(
|
||||||
|
user.role === "admin" ||
|
||||||
|
user.role === "super_admin" ||
|
||||||
|
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
|
||||||
|
);
|
||||||
|
return hasPermission ? <ReferralManagement /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route guard for sign contract
|
||||||
|
function SignContractGuard({ children }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [userStatus, setUserStatus] = React.useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function checkStatus() {
|
||||||
|
log("[SignContractGuard] Checking contract eligibility...");
|
||||||
|
log("[SignContractGuard] user:", user);
|
||||||
|
log("[SignContractGuard] accessToken:", accessToken ? accessToken.substring(0, 20) + "..." : null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${import.meta.env.VITE_API_BASE_URL}/api/auth/user/status-progress`;
|
||||||
|
log("[SignContractGuard] Fetching status from:", url);
|
||||||
|
const res = await fetch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("[SignContractGuard] Response status:", res.status);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch user status");
|
||||||
|
const data = await res.json();
|
||||||
|
log("[SignContractGuard] Response data:", data);
|
||||||
|
|
||||||
|
// Accept both old and new API formats
|
||||||
|
let eligible = false;
|
||||||
|
if (data.status) {
|
||||||
|
// Old format: status fields
|
||||||
|
eligible =
|
||||||
|
data.status.email_verified === 1 &&
|
||||||
|
data.status.documents_uploaded === 1 &&
|
||||||
|
data.status.profile_completed === 1;
|
||||||
|
} else if (data.progress) {
|
||||||
|
// New format: progress steps array
|
||||||
|
const steps = data.progress.steps || [];
|
||||||
|
const required = ["email_verified", "profile_completed", "documents_uploaded"];
|
||||||
|
eligible = required.every(
|
||||||
|
key =>
|
||||||
|
steps.find(s => s.key === key && (s.completed === true || s.completed === 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setUserStatus(data.status || data.progress);
|
||||||
|
|
||||||
|
if (!eligible) {
|
||||||
|
log("[SignContractGuard] Missing steps. Blocking access.");
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: "Please complete email verification, ID upload, and profile completion before signing the contract."
|
||||||
|
});
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
} else {
|
||||||
|
log("[SignContractGuard] All steps completed. Allowing access.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("[SignContractGuard] Error fetching status:", err);
|
||||||
|
showToast({ type: "error", message: "Unable to check contract eligibility." });
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkStatus();
|
||||||
|
}, [navigate, accessToken, user]);
|
||||||
|
|
||||||
|
// Accept both old and new API formats for eligibility
|
||||||
|
let eligible = false;
|
||||||
|
if (userStatus) {
|
||||||
|
if (userStatus.email_verified !== undefined) {
|
||||||
|
eligible =
|
||||||
|
userStatus.email_verified === 1 &&
|
||||||
|
userStatus.documents_uploaded === 1 &&
|
||||||
|
userStatus.profile_completed === 1;
|
||||||
|
} else if (userStatus.steps) {
|
||||||
|
const steps = userStatus.steps;
|
||||||
|
const required = ["email_verified", "profile_completed", "documents_uploaded"];
|
||||||
|
eligible = required.every(
|
||||||
|
key =>
|
||||||
|
steps.find(s => s.key === key && (s.completed === true || s.completed === 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eligible) {
|
||||||
|
log("[SignContractGuard] Not eligible for contract route. Rendering null.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log("[SignContractGuard] Eligible for contract route. Rendering children.");
|
||||||
|
return children;
|
||||||
|
}
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
41
src/auth/AdminAuthMiddleware.jsx
Normal file
41
src/auth/AdminAuthMiddleware.jsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
|
function AdminAuthMiddleware({ children }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAuthReady = useAuthStore((s) => s.isAuthReady);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
log("🔍 AdminAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthReady) return;
|
||||||
|
if (user && user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
log("AdminAuthMiddleware: Access denied for user role:", user.role);
|
||||||
|
toast.error("Access denied. You are not permitted to access admin areas.");
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthReady, user, navigate]);
|
||||||
|
|
||||||
|
if (!isAuthReady) {
|
||||||
|
// Wait for bootstrap refresh to complete
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
log("AdminAuthMiddleware: No user, redirecting to /login");
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
// Don't render anything while redirecting
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminAuthMiddleware;
|
||||||
186
src/auth/AuthWrapper.jsx
Normal file
186
src/auth/AuthWrapper.jsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
|
// Helper to check if JWT is expired
|
||||||
|
function isTokenExpired(token) {
|
||||||
|
if (!token) return true;
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
const { exp } = JSON.parse(atob(payload));
|
||||||
|
return exp * 1000 < Date.now();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to decode JWT and get expiry
|
||||||
|
function getTokenExpiry(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
const { exp } = JSON.parse(atob(payload));
|
||||||
|
return exp ? new Date(exp * 1000) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthWrapper({ children }) {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const setAccessToken = useAuthStore((s) => s.setAccessToken);
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
const clearAuth = useAuthStore((s) => s.clearAuth);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const isAuthReady = useAuthStore((s) => s.isAuthReady);
|
||||||
|
const setAuthReady = useAuthStore((s) => s.setAuthReady);
|
||||||
|
const refreshAuthToken = useAuthStore((s) => s.refreshAuthToken);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isRefreshingRef = useRef(false);
|
||||||
|
const refreshTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
log("🛡️ AuthWrapper: Component rendered");
|
||||||
|
log("🔍 AuthWrapper: Current token:", accessToken ? `${accessToken.substring(0, 20)}...` : "None");
|
||||||
|
if (accessToken) {
|
||||||
|
const expiry = getTokenExpiry(accessToken);
|
||||||
|
log("⏳ AuthWrapper: Current token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initial bootstrap: attempt refresh via httpOnly cookie to obtain access token ---
|
||||||
|
useEffect(() => {
|
||||||
|
log("🔄 AuthWrapper: Bootstrapping auth via store.refreshAuthToken()");
|
||||||
|
let mounted = true;
|
||||||
|
const doBootstrapRefresh = async () => {
|
||||||
|
try {
|
||||||
|
// call centralized, deduped refresh
|
||||||
|
const ok = await refreshAuthToken();
|
||||||
|
if (!ok) {
|
||||||
|
log("❌ AuthWrapper: Bootstrap refresh returned false — performing logout+redirect");
|
||||||
|
await logout().catch((e) => log("❌ AuthWrapper: logout error during bootstrap:", e));
|
||||||
|
if (mounted) navigate("/login", { replace: true });
|
||||||
|
} else {
|
||||||
|
log("✅ AuthWrapper: Bootstrap refresh succeeded (store set token/user)");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ AuthWrapper: Bootstrap refresh error:", error);
|
||||||
|
await logout().catch((e) => log("❌ AuthWrapper: logout error during bootstrap:", e));
|
||||||
|
if (mounted) navigate("/login", { replace: true });
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setAuthReady(true);
|
||||||
|
log("🔔 AuthWrapper: Auth ready set to true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doBootstrapRefresh();
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Periodic Token Check ---
|
||||||
|
useEffect(() => {
|
||||||
|
log("🕒 AuthWrapper: Setting up periodic token expiry check (every 14 minutes)");
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
log("🕒 AuthWrapper: Periodic check running");
|
||||||
|
if (accessToken && isTokenExpired(accessToken)) {
|
||||||
|
log("⏰ AuthWrapper: Token expired (periodic check), attempting refresh");
|
||||||
|
const refreshed = await refreshAuthToken();
|
||||||
|
if (!refreshed) {
|
||||||
|
log("❌ AuthWrapper: Token refresh failed or expired (periodic check), logging out user");
|
||||||
|
clearAuth();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log("🟢 AuthWrapper: Token still valid (periodic check)");
|
||||||
|
}
|
||||||
|
}, 14 * 60 * 1000); // every 14 minutes
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
log("🛑 AuthWrapper: Clearing periodic token expiry check interval");
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [accessToken, navigate]);
|
||||||
|
|
||||||
|
// --- Automatic Token Refresh Timer ---
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
log("🛑 AuthWrapper: Clearing previous scheduled refresh timer");
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
refreshTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to schedule refresh
|
||||||
|
function scheduleRefresh() {
|
||||||
|
if (!accessToken) {
|
||||||
|
log("⚠️ AuthWrapper: No accessToken, not scheduling refresh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiry = getTokenExpiry(accessToken);
|
||||||
|
if (!expiry) {
|
||||||
|
log("⚠️ AuthWrapper: Could not determine token expiry, not scheduling refresh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 10 seconds before expiry, but never less than 1 second from now
|
||||||
|
const now = Date.now();
|
||||||
|
const msUntilExpiry = expiry.getTime() - now;
|
||||||
|
const msUntilRefresh = Math.max(msUntilExpiry - 10000, 1000);
|
||||||
|
|
||||||
|
if (msUntilRefresh > 0) {
|
||||||
|
log(
|
||||||
|
`⏰ AuthWrapper: Scheduling token refresh in ${Math.round(msUntilRefresh / 1000)} seconds (at ${new Date(now + msUntilRefresh).toLocaleTimeString()})`
|
||||||
|
);
|
||||||
|
refreshTimeoutRef.current = setTimeout(async () => {
|
||||||
|
log("🚨 AuthWrapper: Scheduled token refresh timer triggered");
|
||||||
|
await refreshAuthToken();
|
||||||
|
}, msUntilRefresh);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`⚠️ AuthWrapper: Token expiry is too soon (${msUntilExpiry}ms), not scheduling refresh`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRefresh();
|
||||||
|
|
||||||
|
// Cleanup on unmount or accessToken change
|
||||||
|
return () => {
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
log("🛑 AuthWrapper: Cleaning up scheduled refresh timer on unmount/accessToken change");
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
refreshTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [accessToken]); // Reschedule when accessToken changes
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthReady) return;
|
||||||
|
// If no accessToken or user, force redirect to login
|
||||||
|
if (!accessToken || !user) {
|
||||||
|
log("🔒 AuthWrapper: No accessToken or user, redirecting to /login");
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthReady, accessToken, user, navigate]);
|
||||||
|
|
||||||
|
if (!isAuthReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading authentication...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthWrapper;
|
||||||
43
src/auth/CompanyAuthMiddleware.jsx
Normal file
43
src/auth/CompanyAuthMiddleware.jsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
|
function CompanyAuthMiddleware({ children }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAuthReady = useAuthStore((s) => s.isAuthReady);
|
||||||
|
|
||||||
|
log("🔍 CompanyAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
|
||||||
|
|
||||||
|
// Wait for auth bootstrap to finish before deciding
|
||||||
|
if (!isAuthReady) {
|
||||||
|
log("⏳ CompanyAuthMiddleware: Auth not ready yet, deferring decision");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
log("🔒 CompanyAuthMiddleware: No user found, redirecting to /login");
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive userType from user object safely
|
||||||
|
const getUserType = (u) => {
|
||||||
|
if (!u) return null;
|
||||||
|
if (u.userType) return u.userType;
|
||||||
|
if (u.type) return u.type;
|
||||||
|
if (u.companyName) return "company";
|
||||||
|
return "personal";
|
||||||
|
};
|
||||||
|
|
||||||
|
const userType = getUserType(user);
|
||||||
|
log("🔍 CompanyAuthMiddleware: derived userType:", userType);
|
||||||
|
|
||||||
|
if (userType !== "company") {
|
||||||
|
log("⛔ CompanyAuthMiddleware: userType mismatch, redirecting to /dashboard");
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CompanyAuthMiddleware;
|
||||||
44
src/auth/OtpForm.jsx
Normal file
44
src/auth/OtpForm.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function OtpForm() {
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-sm sm:max-w-2xl mx-auto bg-white rounded-2xl shadow-2xl px-6 py-8 sm:px-12 sm:py-10 mt-8 mb-8">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-extrabold text-blue-900 mb-2 text-center">{t('otp.heading')}</h2>
|
||||||
|
<p className="text-sm sm:text-base text-gray-500 mb-6 text-center">
|
||||||
|
{t('otp.instructions')}
|
||||||
|
</p>
|
||||||
|
<form className="space-y-6 sm:space-y-7">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="otp" className="block text-sm font-medium text-blue-900 mb-2 text-center">
|
||||||
|
{t('otp.codeLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="otp"
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border border-blue-200 bg-gray-50 text-lg text-blue-900 placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 transition text-center tracking-widest"
|
||||||
|
placeholder={t('otp.codePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all"
|
||||||
|
>
|
||||||
|
{t('otp.verifyAndLogin')}
|
||||||
|
</button>
|
||||||
|
<div className="flex justify-center mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-700 hover:text-blue-900 font-medium text-sm px-3 py-2 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
{t('otp.resendCode')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OtpForm;
|
||||||
43
src/auth/PersonalAuthMiddleware.jsx
Normal file
43
src/auth/PersonalAuthMiddleware.jsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
|
function PersonalAuthMiddleware({ children }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAuthReady = useAuthStore((s) => s.isAuthReady);
|
||||||
|
|
||||||
|
log("🔍 PersonalAuthMiddleware: isAuthReady:", isAuthReady, "user:", user);
|
||||||
|
|
||||||
|
// Wait for auth bootstrap to finish before deciding
|
||||||
|
if (!isAuthReady) {
|
||||||
|
log("⏳ PersonalAuthMiddleware: Auth not ready yet, deferring decision");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
log("🔒 PersonalAuthMiddleware: No user found, redirecting to /login");
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive userType from user object safely
|
||||||
|
const getUserType = (u) => {
|
||||||
|
if (!u) return null;
|
||||||
|
if (u.userType) return u.userType;
|
||||||
|
if (u.type) return u.type;
|
||||||
|
if (u.companyName) return "company";
|
||||||
|
return "personal";
|
||||||
|
};
|
||||||
|
|
||||||
|
const userType = getUserType(user);
|
||||||
|
log("🔍 PersonalAuthMiddleware: derived userType:", userType);
|
||||||
|
|
||||||
|
if (userType !== "personal") {
|
||||||
|
log("⛔ PersonalAuthMiddleware: userType mismatch, redirecting to /dashboard");
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonalAuthMiddleware;
|
||||||
20
src/auth/ProtectedRoute.jsx
Normal file
20
src/auth/ProtectedRoute.jsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../../store/authStore";
|
||||||
|
|
||||||
|
function ProtectedRoute({ children, allowedRoles = [] }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure allowedRoles is an array and check if the user's userType or role matches
|
||||||
|
if (!Array.isArray(allowedRoles) || (!allowedRoles.includes(user.userType) && !allowedRoles.includes(user.role))) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
109
src/background/GlobalAnimatedBackground.jsx
Normal file
109
src/background/GlobalAnimatedBackground.jsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Utility to detect mobile devices
|
||||||
|
function isMobileDevice() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalAnimatedBackground() {
|
||||||
|
// Always use dashboard style for a uniform look
|
||||||
|
const bgGradient = "linear-gradient(135deg, #1e293b 0%, #334155 100%)";
|
||||||
|
|
||||||
|
// Detect small screens (mobile/tablet)
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Render only the static background gradient and overlay, no animation
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: bgGradient,
|
||||||
|
transition: "background 0.5s",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: bgGradient,
|
||||||
|
transition: "background 0.5s",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Overlays */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
||||||
|
<div className="absolute top-10 left-10 w-64 h-1 bg-blue-300 opacity-50 animate-slide-loop"></div>
|
||||||
|
<div className="absolute bottom-20 right-20 w-48 h-1 bg-blue-200 opacity-40 animate-slide-loop"></div>
|
||||||
|
<div className="absolute top-1/3 left-1/4 w-72 h-1 bg-blue-400 opacity-30 animate-slide-loop"></div>
|
||||||
|
<div className="absolute top-16 left-1/3 w-32 h-32 bg-blue-500 rounded-full opacity-50 animate-float"></div>
|
||||||
|
<div className="absolute bottom-24 right-1/4 w-40 h-40 bg-blue-600 rounded-full opacity-40 animate-float"></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 w-24 h-24 bg-blue-700 rounded-full opacity-30 animate-float"></div>
|
||||||
|
<div className="absolute top-1/4 left-1/5 w-20 h-20 bg-blue-300 rounded-lg opacity-40 animate-float-slow"></div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/3 w-28 h-28 bg-blue-400 rounded-lg opacity-30 animate-float-slow"></div>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-loop {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100vw);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: float-slow 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-slide-loop {
|
||||||
|
animation: slide-loop 12s linear infinite;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalAnimatedBackground;
|
||||||
54
src/background/GlobalMobileBackground.jsx
Normal file
54
src/background/GlobalMobileBackground.jsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const GlobalMobileBackground = () => (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 w-screen h-screen z-0 pointer-events-none bg-gradient-to-br from-blue-900 via-blue-800 to-blue-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 430 932"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
style={{ zIndex: 0 }}
|
||||||
|
>
|
||||||
|
{/* Thin diagonal lines */}
|
||||||
|
<line
|
||||||
|
x1="30"
|
||||||
|
y1="100"
|
||||||
|
x2="400"
|
||||||
|
y2="200"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="2"
|
||||||
|
opacity="0.16"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="400"
|
||||||
|
x2="430"
|
||||||
|
y2="600"
|
||||||
|
stroke="#2563eb"
|
||||||
|
strokeWidth="2"
|
||||||
|
opacity="0.13"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="50"
|
||||||
|
y1="800"
|
||||||
|
x2="380"
|
||||||
|
y2="900"
|
||||||
|
stroke="#1e40af"
|
||||||
|
strokeWidth="2"
|
||||||
|
opacity="0.13"
|
||||||
|
/>
|
||||||
|
{/* Subtle circles */}
|
||||||
|
<circle cx="60" cy="60" r="18" fill="#3b82f6" opacity="0.13" />
|
||||||
|
<circle cx="370" cy="120" r="12" fill="#2563eb" opacity="0.11" />
|
||||||
|
<circle cx="100" cy="870" r="24" fill="#1e40af" opacity="0.11" />
|
||||||
|
<circle cx="350" cy="850" r="16" fill="#818cf8" opacity="0.09" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GlobalMobileBackground;
|
||||||
42
src/features/PageLayout.jsx
Normal file
42
src/features/PageLayout.jsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Header from "./nav/Header";
|
||||||
|
import Footer from "./nav/Footer";
|
||||||
|
import GlobalAnimatedBackground from "../background/GlobalAnimatedBackground";
|
||||||
|
import GlobalMobileBackground from "../background/GlobalMobileBackground"; // import mobile background
|
||||||
|
import { log } from "../utils/logger"; // import logger
|
||||||
|
|
||||||
|
// Utility to detect mobile devices
|
||||||
|
function isMobileDevice() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageLayout({ children, showHeader = true, showFooter = true }) {
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
log("📱 PageLayout: isMobile =", isMobile);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full flex flex-col relative overflow-x-hidden bg-white">
|
||||||
|
{/* Background only behind content, header, and footer */}
|
||||||
|
<div className="absolute inset-0 w-full h-full z-0 pointer-events-none">
|
||||||
|
{!isMobile && <GlobalAnimatedBackground />}
|
||||||
|
{isMobile && <GlobalMobileBackground />}
|
||||||
|
</div>
|
||||||
|
{showHeader && (
|
||||||
|
<div className="relative z-50 w-full">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 relative z-10 w-full flex flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{showFooter && (
|
||||||
|
<div className="relative z-20 w-full">
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageLayout;
|
||||||
110
src/features/RouteProtection.jsx
Normal file
110
src/features/RouteProtection.jsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { showToast } from "./toast/toastUtils.js";
|
||||||
|
import { log } from "../utils/logger"; // import logger
|
||||||
|
|
||||||
|
function RouteProtection({ children, requiredUserType, requiredRole }) {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
|
log("🛡️ RouteProtection: Checking access");
|
||||||
|
log("🔍 User:", user);
|
||||||
|
log("🔑 Token:", accessToken ? "Present" : "Missing");
|
||||||
|
log("📋 Required User Type:", requiredUserType);
|
||||||
|
log("🎯 Required Role:", requiredRole);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermissions = () => {
|
||||||
|
log("🔍 RouteProtection: Starting permission check");
|
||||||
|
|
||||||
|
// If no token, redirect to login
|
||||||
|
if (!accessToken) {
|
||||||
|
log("❌ RouteProtection: No token, redirecting to login");
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no user data, wait a moment for it to load
|
||||||
|
if (!user) {
|
||||||
|
log("⏳ RouteProtection: No user data, waiting...");
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
log("⏰ RouteProtection: Timeout waiting for user data");
|
||||||
|
setIsChecking(false);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("👤 RouteProtection: User data available");
|
||||||
|
log("🔍 User Type:", user.userType);
|
||||||
|
log("🔍 User Role:", user.role);
|
||||||
|
|
||||||
|
// Check user type if required
|
||||||
|
if (requiredUserType && user.userType !== requiredUserType) {
|
||||||
|
log(`❌ RouteProtection: User type mismatch. Required: ${requiredUserType}, Got: ${user.userType}`);
|
||||||
|
showToast({ type: "error", message: `Access denied. This page is only for ${requiredUserType} users.` });
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role if required (admin role protection)
|
||||||
|
if (requiredRole) {
|
||||||
|
const userRole = user.role || 'user'; // Default to 'user' if role is undefined
|
||||||
|
log(`🔍 RouteProtection: Role check. Required: ${requiredRole}, Got: ${userRole}`);
|
||||||
|
|
||||||
|
if (userRole !== requiredRole) {
|
||||||
|
log(`❌ RouteProtection: Role mismatch. Required: ${requiredRole}, Got: ${userRole}`);
|
||||||
|
if (requiredRole === 'admin') {
|
||||||
|
showToast({ type: "error", message: "Access denied. You are not permitted to access admin areas." });
|
||||||
|
} else {
|
||||||
|
showToast({ type: "error", message: `Access denied. You don't have the required permissions (${requiredRole}).` });
|
||||||
|
}
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("✅ RouteProtection: Access granted");
|
||||||
|
setIsChecking(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermissions();
|
||||||
|
}, [user, accessToken, requiredUserType, requiredRole, navigate]);
|
||||||
|
|
||||||
|
// Show loading while checking permissions
|
||||||
|
if (isChecking) {
|
||||||
|
log("⏳ RouteProtection: Still checking permissions...");
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Checking permissions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
log("❌ RouteProtection: No user data after timeout");
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600">Authentication error. Please try logging in again.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("✅ RouteProtection: Rendering protected content");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RouteProtection;
|
||||||
62
src/features/admin/adminDashboard/api/adminDashboardApi.js
Normal file
62
src/features/admin/adminDashboard/api/adminDashboardApi.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { authFetch } from "../../../../utils/authFetch";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export async function fetchAdminUserStats(accessToken) {
|
||||||
|
log("fetchAdminUserStats called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-stats`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchAdminUserStats failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchAdminUserStats success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminPermissions(accessToken) {
|
||||||
|
log("fetchAdminPermissions called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/permissions`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchAdminPermissions failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchAdminPermissions success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminServerStatus(accessToken) {
|
||||||
|
log("fetchAdminServerStatus called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/server-status`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchAdminServerStatus failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchAdminServerStatus success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAdminPermissions } from "../hooks/useAdminDashboard";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function StatusPermissionManagement() {
|
||||||
|
const { permissions, loading } = useAdminPermissions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation("admin_dashboard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-purple-50 rounded-xl shadow p-8 flex flex-col cursor-pointer hover:shadow-xl transition"
|
||||||
|
onClick={() => navigate("/admin/permission-management")}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label="Go to Permission Management"
|
||||||
|
>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-purple-400 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-purple-900">
|
||||||
|
{t("sections.permissionManagement")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
{t("descriptions.permissionManagement")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.permissions")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : permissions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mt-auto px-4 py-2 bg-purple-100 text-purple-700 rounded border border-purple-300 font-semibold hover:bg-purple-200 transition"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate("/admin/permission-management");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.goPermissionManagement")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusPermissionManagement;
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useAdminServerStatus } from "../hooks/useAdminDashboard";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function formatUptime(uptime) {
|
||||||
|
if (!uptime) return "-";
|
||||||
|
if (typeof uptime === "string") return uptime;
|
||||||
|
if (typeof uptime === "object" && (uptime.days !== undefined || uptime.hours !== undefined)) {
|
||||||
|
const { days = 0, hours = 0, minutes = 0 } = uptime;
|
||||||
|
let str = "";
|
||||||
|
if (days) str += `${days} day${days !== 1 ? "s" : ""}`;
|
||||||
|
if (hours) str += (str ? ", " : "") + `${hours} hour${hours !== 1 ? "s" : ""}`;
|
||||||
|
if (minutes) str += (str ? ", " : "") + `${minutes} min${minutes !== 1 ? "s" : ""}`;
|
||||||
|
return str || "-";
|
||||||
|
}
|
||||||
|
return String(uptime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemory(memory) {
|
||||||
|
if (!memory) return "-";
|
||||||
|
if (typeof memory === "object" && memory.used !== undefined && memory.total !== undefined) {
|
||||||
|
return `${memory.used} / ${memory.total}`;
|
||||||
|
}
|
||||||
|
return String(memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLog(log) {
|
||||||
|
// Handles log objects like {time, level, message}
|
||||||
|
if (typeof log === "object" && log !== null) {
|
||||||
|
const time = log.time || log.timestamp || "";
|
||||||
|
const level = log.level || "";
|
||||||
|
const message = log.message || "";
|
||||||
|
return `[${time}] ${level}: ${message}`;
|
||||||
|
}
|
||||||
|
return String(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusServerLogs() {
|
||||||
|
const { server, loading } = useAdminServerStatus();
|
||||||
|
const { t } = useTranslation('admin_dashboard');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 mt-4">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<svg className="w-8 h-8 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 17a1 1 0 001 1h14a1 1 0 001-1V7a1 1 0 00-1-1H5a1 1 0 00-1 1v10zm0 0V7m16 10V7M8 11h8M8 15h4"/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{t('sections.serverStatusLogs')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t('server.status')}:{" "}
|
||||||
|
<span className={`font-semibold ${server?.status === "online" ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{loading ? "..." : (server?.status ? t(`server.${server.status}`) : t('server.offline'))}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t('server.uptime')}: <span className="font-semibold">{loading ? "..." : formatUptime(server?.uptime)}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t('server.cpuUsage')}: <span className="font-semibold">{loading ? "..." : (server?.cpuUsagePercent !== undefined ? `${server.cpuUsagePercent}%` : "-")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t('server.memoryUsage')}: <span className="font-semibold">{loading ? "..." : formatMemory(server?.memory)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">{t('server.recentErrorLogs')}</h3>
|
||||||
|
<ul className="text-sm text-red-500 space-y-1">
|
||||||
|
{loading ? (
|
||||||
|
<li>{t('server.loadingLogs')}</li>
|
||||||
|
) : server?.logs && Array.isArray(server.logs) && server.logs.length > 0 ? (
|
||||||
|
server.logs.map((log, idx) => (
|
||||||
|
<li key={idx}>{formatLog(log)}</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li>{t('server.noLogs')}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusServerLogs;
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAdminUserStats } from "../hooks/useAdminDashboard";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function StatusUserManagement() {
|
||||||
|
const { stats, loading } = useAdminUserStats();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation("admin_dashboard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-blue-50 rounded-xl shadow p-8 flex flex-col cursor-pointer hover:shadow-xl transition"
|
||||||
|
onClick={() => navigate("/admin/user-management")}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label="Go to User Management"
|
||||||
|
>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-blue-400 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-blue-900">
|
||||||
|
{t("sections.userManagement")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
{t("descriptions.userManagement")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.totalUsers")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.totalUsers ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.adminUsers")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.adminUsers ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.verificationPending")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.verificationPending ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.activeUsers")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.activeUsers ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.personalUsers")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.personalUsers ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-700">
|
||||||
|
<span>{t("stats.companyUsers")}</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{loading ? "..." : stats?.companyUsers ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mt-auto px-4 py-2 bg-orange-100 text-orange-700 rounded border border-orange-300 font-semibold hover:bg-orange-200 transition"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate("/admin/verify-users");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.verifyUsers")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusUserManagement;
|
||||||
101
src/features/admin/adminDashboard/hooks/useAdminDashboard.js
Normal file
101
src/features/admin/adminDashboard/hooks/useAdminDashboard.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import {
|
||||||
|
fetchAdminUserStats,
|
||||||
|
fetchAdminPermissions,
|
||||||
|
fetchAdminServerStatus,
|
||||||
|
} from "../api/adminDashboardApi";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export function useAdminUserStats() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useAdminUserStats: loading stats");
|
||||||
|
fetchAdminUserStats(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useAdminUserStats: fetched stats", data);
|
||||||
|
setStats(data.stats || data);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useAdminUserStats: error loading stats", err);
|
||||||
|
setError("Failed to load user stats: " + err.message);
|
||||||
|
setStats(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { stats, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminPermissions() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [permissions, setPermissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useAdminPermissions: loading permissions");
|
||||||
|
fetchAdminPermissions(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useAdminPermissions: fetched permissions", data);
|
||||||
|
setPermissions(data.permissions || []);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useAdminPermissions: error loading permissions", err);
|
||||||
|
setError("Failed to load permissions: " + err.message);
|
||||||
|
setPermissions([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { permissions, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminServerStatus() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [server, setServer] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useAdminServerStatus: loading server status");
|
||||||
|
fetchAdminServerStatus(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useAdminServerStatus: fetched server status", data);
|
||||||
|
setServer(data);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useAdminServerStatus: error loading server status", err);
|
||||||
|
setError("Failed to load server status: " + err.message);
|
||||||
|
setServer(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { server, loading, error, reload: load };
|
||||||
|
}
|
||||||
75
src/features/admin/adminDashboard/pages/AdminDashboard.jsx
Normal file
75
src/features/admin/adminDashboard/pages/AdminDashboard.jsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import RouteProtection from "../../../RouteProtection";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import StatusUserManagement from "../components/StatusUserManagement";
|
||||||
|
import StatusPermissionManagement from "../components/StatusPermissionManagement";
|
||||||
|
import StatusServerLogs from "../components/StatusServerLogs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function AdminDashboard() {
|
||||||
|
const { t } = useTranslation('admin_dashboard');
|
||||||
|
return (
|
||||||
|
<RouteProtection requiredRole="admin">
|
||||||
|
<PageLayout showHeader={true} showFooter={true}>
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="relative z-10 w-full flex justify-center">
|
||||||
|
<div
|
||||||
|
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5%", // Match dashboard/referral management top margin
|
||||||
|
marginBottom: "2%",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "1600px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header and subheader, consistent with Dashboard/Referral Management */}
|
||||||
|
<h2 className="text-2xl sm:text-5xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
|
||||||
|
{t('heading.main')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
|
||||||
|
{t('subtitle.main')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white bg-opacity-90 rounded-lg shadow-lg p-4 sm:p-6 mb-8">
|
||||||
|
{/* Prominent warning banner */}
|
||||||
|
<div className="w-full mb-8">
|
||||||
|
<div className="flex items-center w-full bg-red-100 border-2 border-red-500 rounded-xl shadow px-4 sm:px-8 py-4 sm:py-5">
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 sm:w-8 sm:h-8 text-red-600 mr-3 sm:mr-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 9v2m0 4h.01M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-bold text-red-700 text-base sm:text-xl">
|
||||||
|
{t('warning.banner')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base text-center">
|
||||||
|
{t('subtitle.main')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8 mb-12">
|
||||||
|
<StatusUserManagement />
|
||||||
|
<StatusPermissionManagement />
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 sm:mt-16">
|
||||||
|
<StatusServerLogs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
</RouteProtection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
// Dummy hook for future API integration
|
||||||
|
const useContractDashboardApi = () => {
|
||||||
|
// Will fetch documents from backend later
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useContractDashboardApi;
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import useContractDashboard from "../hooks/useContractDashboard";
|
||||||
|
|
||||||
|
const TemplateStateButton = ({ id, state, refreshTemplates }) => {
|
||||||
|
const { updateTemplateState, uploading } = useContractDashboard();
|
||||||
|
const [localState, setLocalState] = useState(state);
|
||||||
|
|
||||||
|
const handleToggleState = async () => {
|
||||||
|
const newState = localState === "active" ? "inactive" : "active";
|
||||||
|
await updateTemplateState(id, newState);
|
||||||
|
setLocalState(newState);
|
||||||
|
if (typeof refreshTemplates === "function") {
|
||||||
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-2 py-1 rounded font-semibold text-xs ${
|
||||||
|
localState === "active"
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: "bg-gray-400 text-white"
|
||||||
|
}`}
|
||||||
|
onClick={handleToggleState}
|
||||||
|
disabled={uploading}
|
||||||
|
title={localState === "active" ? "Deaktivieren" : "Aktivieren"}
|
||||||
|
>
|
||||||
|
{localState === "active" ? "Aktiv" : "Inaktiv"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateStateButton;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// This is just a placeholder, main logic is in the page file
|
||||||
|
const ContractDashboardForm = () => {
|
||||||
|
// ...form logic can be moved here later...
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractDashboardForm;
|
||||||
@ -0,0 +1,359 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import useContractDashboard from "../hooks/useContractDashboard";
|
||||||
|
|
||||||
|
const CreateContractForm = ({
|
||||||
|
templateHtml,
|
||||||
|
setTemplateHtml,
|
||||||
|
templateName,
|
||||||
|
setTemplateName,
|
||||||
|
codeView,
|
||||||
|
setCodeView,
|
||||||
|
editorRef,
|
||||||
|
execCmd,
|
||||||
|
refreshTemplates,
|
||||||
|
selectedDoc,
|
||||||
|
}) => {
|
||||||
|
// Sync contentEditable innerHTML with templateHtml when switching from code view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!codeView && editorRef.current) {
|
||||||
|
// Previously we stripped <style data-editor-style> and body-only content.
|
||||||
|
// Now we keep the raw templateHtml exactly as provided.
|
||||||
|
const incoming = typeof templateHtml === "string" ? templateHtml : "";
|
||||||
|
if (editorRef.current.innerHTML !== incoming) {
|
||||||
|
editorRef.current.innerHTML = incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [codeView, templateHtml, editorRef]);
|
||||||
|
|
||||||
|
// Remove automatic editor list style injection (it could overwrite user <style> tags)
|
||||||
|
// useEffect removed.
|
||||||
|
|
||||||
|
// Add state for type, description, lang, and upload feedback
|
||||||
|
const [templateType, setTemplateType] = useState("");
|
||||||
|
const [templateDesc, setTemplateDesc] = useState("");
|
||||||
|
const [templateLang, setTemplateLang] = useState("de");
|
||||||
|
// Add audience state
|
||||||
|
const [templateAudience, setTemplateAudience] = useState("both");
|
||||||
|
// Add edit mode state
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [templateState, setTemplateState] = useState("active");
|
||||||
|
|
||||||
|
// Helper to extract the inner content from a full HTML document
|
||||||
|
function extractHtmlBodyContent(html) {
|
||||||
|
return html || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch template file content for editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode && selectedDoc) {
|
||||||
|
setTemplateName(selectedDoc.name || "");
|
||||||
|
setTemplateType(selectedDoc.type || "");
|
||||||
|
setTemplateDesc(selectedDoc.description || "");
|
||||||
|
setTemplateLang(selectedDoc.lang || "de");
|
||||||
|
setTemplateState(selectedDoc.state || "active");
|
||||||
|
setTemplateAudience(selectedDoc.audience || "both");
|
||||||
|
if (selectedDoc.html) {
|
||||||
|
setTemplateHtml(selectedDoc.html); // keep untouched
|
||||||
|
} else if (selectedDoc.fileUrl) {
|
||||||
|
fetch(selectedDoc.fileUrl)
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(html => setTemplateHtml(html))
|
||||||
|
.catch(() => setTemplateHtml(""));
|
||||||
|
} else {
|
||||||
|
setTemplateHtml("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editMode, selectedDoc, setTemplateHtml, setTemplateName, setTemplateType, setTemplateDesc, setTemplateLang, setTemplateState, setTemplateAudience]);
|
||||||
|
|
||||||
|
// Use hook for upload logic
|
||||||
|
const { uploadTemplate, uploading, uploadMsg, updateTemplate, updateTemplateState } = useContractDashboard();
|
||||||
|
|
||||||
|
// Helper to get the correct HTML content for saving
|
||||||
|
const getHtmlToSave = () => {
|
||||||
|
// Do not strip styles or wrap—save exactly what is in code view or editor.
|
||||||
|
if (codeView) return templateHtml;
|
||||||
|
return editorRef.current?.innerHTML || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to wrap content in valid HTML document structure
|
||||||
|
function wrapHtmlDocument(content) {
|
||||||
|
// No wrapping or alteration now; user supplies final HTML.
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload handler (create)
|
||||||
|
const handleSaveTemplate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Debug
|
||||||
|
console.debug("Create submit audience =", templateAudience);
|
||||||
|
const htmlToSave = getHtmlToSave();
|
||||||
|
const wrappedHtml = wrapHtmlDocument(htmlToSave);
|
||||||
|
await uploadTemplate({
|
||||||
|
name: templateName,
|
||||||
|
type: templateType,
|
||||||
|
description: templateDesc,
|
||||||
|
lang: templateLang,
|
||||||
|
audience: templateAudience,
|
||||||
|
html: wrappedHtml,
|
||||||
|
setTemplateName,
|
||||||
|
setTemplateType,
|
||||||
|
setTemplateDesc,
|
||||||
|
setTemplateHtml,
|
||||||
|
setTemplateAudience,
|
||||||
|
});
|
||||||
|
// After successful upload, refresh the template list
|
||||||
|
if (typeof refreshTemplates === "function") {
|
||||||
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit handler (update)
|
||||||
|
const handleUpdateTemplate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.debug("Update submit audience =", templateAudience);
|
||||||
|
const htmlToSave = getHtmlToSave();
|
||||||
|
const wrappedHtml = wrapHtmlDocument(htmlToSave);
|
||||||
|
await updateTemplate({
|
||||||
|
id: selectedDoc.id,
|
||||||
|
name: templateName,
|
||||||
|
type: templateType,
|
||||||
|
description: templateDesc,
|
||||||
|
lang: templateLang,
|
||||||
|
audience: templateAudience,
|
||||||
|
html: wrappedHtml,
|
||||||
|
});
|
||||||
|
setEditMode(false);
|
||||||
|
if (typeof refreshTemplates === "function") {
|
||||||
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// State change handler
|
||||||
|
const handleChangeState = async (newState) => {
|
||||||
|
if (!selectedDoc) return;
|
||||||
|
await updateTemplateState(selectedDoc.id, newState);
|
||||||
|
setTemplateState(newState);
|
||||||
|
if (typeof refreshTemplates === "function") {
|
||||||
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit button as a separate component for placement
|
||||||
|
CreateContractForm.EditButton = () =>
|
||||||
|
selectedDoc && !editMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded font-semibold text-sm"
|
||||||
|
onClick={() => setEditMode(true)}
|
||||||
|
>
|
||||||
|
Template bearbeiten
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Code view toggle (in toolbar) - wrap editor content into a full HTML document when switching to code view
|
||||||
|
const codeViewToggleButton = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 border rounded bg-blue-100 text-blue-900"
|
||||||
|
title="Code View"
|
||||||
|
onClick={() => {
|
||||||
|
// No style stripping or wrapping; just toggle.
|
||||||
|
setCodeView(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>
|
||||||
|
{codeView ? "edit" : "code"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3 text-blue-700">
|
||||||
|
{editMode ? "Vertragstemplate bearbeiten" : "Neues Vertragstemplate erstellen"}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={editMode ? handleUpdateTemplate : handleSaveTemplate}
|
||||||
|
className="flex flex-col gap-3 bg-gray-50 p-4 rounded shadow"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Template Name"
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
|
||||||
|
required
|
||||||
|
style={{ minHeight: "32px" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Template Type/Kategorie"
|
||||||
|
value={templateType}
|
||||||
|
onChange={(e) => setTemplateType(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
value={templateDesc}
|
||||||
|
onChange={(e) => setTemplateDesc(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={templateLang}
|
||||||
|
onChange={e => setTemplateLang(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
|
||||||
|
>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="en">Englisch</option>
|
||||||
|
</select>
|
||||||
|
{/* Audience selector */}
|
||||||
|
<select
|
||||||
|
value={templateAudience}
|
||||||
|
onChange={e => setTemplateAudience(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 bg-white text-blue-900 font-medium text-sm"
|
||||||
|
>
|
||||||
|
<option value="personal">Personal</option>
|
||||||
|
<option value="company">Unternehmen</option>
|
||||||
|
<option value="both">Beide</option>
|
||||||
|
</select>
|
||||||
|
{/* Rich Text Editor Toolbar */}
|
||||||
|
<div className="flex gap-1 mb-2 bg-white border border-gray-200 rounded px-1 py-1 flex-wrap items-center">
|
||||||
|
{/* Basic formatting icons (smaller) */}
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Bold" onClick={() => execCmd("bold")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_bold</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Italic" onClick={() => execCmd("italic")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_italic</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Underline" onClick={() => execCmd("underline")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_underlined</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Left" onClick={() => execCmd("justifyLeft")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_align_left</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Center" onClick={() => execCmd("justifyCenter")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_align_center</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1 border rounded bg-white text-blue-900" title="Align Right" onClick={() => execCmd("justifyRight")}>
|
||||||
|
<span className="material-icons" style={{fontSize:16}}>format_align_right</span>
|
||||||
|
</button>
|
||||||
|
{/* Heading dropdown */}
|
||||||
|
<select
|
||||||
|
className="p-1 border rounded bg-white text-blue-900 text-sm"
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val) execCmd("formatBlock", val);
|
||||||
|
e.target.selectedIndex = 0;
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
style={{ minWidth: 70 }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Texttyp</option>
|
||||||
|
<option value="p">Absatz</option>
|
||||||
|
<option value="h1">H1</option>
|
||||||
|
<option value="h2">H2</option>
|
||||||
|
<option value="h3">H3</option>
|
||||||
|
<option value="h4">H4</option>
|
||||||
|
<option value="h5">H5</option>
|
||||||
|
<option value="h6">H6</option>
|
||||||
|
<option value="pre">Code</option>
|
||||||
|
<option value="blockquote">Zitat</option>
|
||||||
|
</select>
|
||||||
|
{/* List dropdown */}
|
||||||
|
<select
|
||||||
|
className="p-1 border rounded bg-white text-blue-900 text-sm"
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === "ul") execCmd("insertUnorderedList");
|
||||||
|
else if (val === "ol") execCmd("insertOrderedList");
|
||||||
|
e.target.selectedIndex = 0;
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
style={{ minWidth: 70 }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Liste</option>
|
||||||
|
<option value="ul">• Liste</option>
|
||||||
|
<option value="ol">1. Liste</option>
|
||||||
|
</select>
|
||||||
|
{/* Link dropdown */}
|
||||||
|
<select
|
||||||
|
className="p-1 border rounded bg-white text-blue-900 text-sm"
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === "link") {
|
||||||
|
const url = prompt("Link URL:");
|
||||||
|
if (url) execCmd("createLink", url);
|
||||||
|
} else if (val === "unlink") execCmd("unlink");
|
||||||
|
e.target.selectedIndex = 0;
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
style={{ minWidth: 80 }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Link</option>
|
||||||
|
<option value="link">Link einfügen</option>
|
||||||
|
<option value="unlink">Link entfernen</option>
|
||||||
|
</select>
|
||||||
|
{/* Code view toggle */}
|
||||||
|
{codeViewToggleButton}
|
||||||
|
</div>
|
||||||
|
{/* Editor or Code View */}
|
||||||
|
{!codeView ? (
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={true}
|
||||||
|
className="contract-editor border rounded px-2 py-1 min-h-[120px] max-h-[400px] bg-white text-blue-900 text-sm overflow-auto"
|
||||||
|
style={{
|
||||||
|
outline: "none",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={e => {
|
||||||
|
// Keep raw HTML (no filtering of <style>)
|
||||||
|
setTemplateHtml(e.currentTarget.innerHTML);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="border rounded px-2 py-1 min-h-[120px] max-h-[400px] font-mono bg-gray-100 text-blue-900 text-sm overflow-auto"
|
||||||
|
value={typeof templateHtml === "string" ? templateHtml : ""}
|
||||||
|
onChange={e => setTemplateHtml(e.target.value)}
|
||||||
|
style={{ resize: "vertical", position: "relative", zIndex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded font-semibold text-sm"
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{editMode
|
||||||
|
? uploading ? "Speichern..." : "Änderungen speichern"
|
||||||
|
: uploading ? "Hochladen..." : "Template speichern"}
|
||||||
|
</button>
|
||||||
|
{editMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-gray-300 hover:bg-gray-400 text-gray-800 px-3 py-1 rounded font-semibold text-sm"
|
||||||
|
onClick={() => setEditMode(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{uploadMsg && (
|
||||||
|
<div className={`mt-2 text-sm ${uploadMsg.includes("erfolgreich") ? "text-green-700" : "text-red-600"}`}>
|
||||||
|
{uploadMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{/* Remove edit button from bottom */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateContractForm;
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
const useContractDashboard = () => {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadMsg, setUploadMsg] = useState("");
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
||||||
|
const uploadTemplate = async ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
lang,
|
||||||
|
audience,
|
||||||
|
html,
|
||||||
|
setTemplateName,
|
||||||
|
setTemplateType,
|
||||||
|
setTemplateDesc,
|
||||||
|
setTemplateHtml,
|
||||||
|
setTemplateAudience,
|
||||||
|
}) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadMsg("");
|
||||||
|
log("uploadTemplate called", { name, type, description, lang, audience });
|
||||||
|
try {
|
||||||
|
const htmlBlob = new Blob([html], { type: "text/html" });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", name);
|
||||||
|
formData.append("type", type);
|
||||||
|
formData.append("description", description);
|
||||||
|
formData.append("lang", lang);
|
||||||
|
formData.append("audience", audience || "both"); // frontend canonical
|
||||||
|
formData.append("user_type", audience || "both"); // backend expected
|
||||||
|
formData.append("file", htmlBlob, "template.html");
|
||||||
|
|
||||||
|
// Debug: list formData entries
|
||||||
|
for (const pair of formData.entries()) {
|
||||||
|
log("uploadTemplate formData", pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + "/api/document-templates",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("uploadTemplate response status:", res.status);
|
||||||
|
if (res.ok) {
|
||||||
|
log("uploadTemplate success");
|
||||||
|
setUploadMsg("Template erfolgreich hochgeladen.");
|
||||||
|
setTemplateName("");
|
||||||
|
setTemplateType("");
|
||||||
|
setTemplateDesc("");
|
||||||
|
setTemplateHtml("");
|
||||||
|
if (setTemplateAudience) setTemplateAudience("both");
|
||||||
|
} else {
|
||||||
|
log("uploadTemplate failed");
|
||||||
|
setUploadMsg("Fehler beim Hochladen des Templates.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("uploadTemplate error:", err);
|
||||||
|
setUploadMsg("Fehler beim Hochladen.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit/update template
|
||||||
|
const updateTemplate = async ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
lang,
|
||||||
|
audience,
|
||||||
|
html,
|
||||||
|
}) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadMsg("");
|
||||||
|
log("updateTemplate called", { id, name, type, description, lang, audience });
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (name) formData.append("name", name);
|
||||||
|
if (type) formData.append("type", type);
|
||||||
|
if (description) formData.append("description", description);
|
||||||
|
if (lang) formData.append("lang", lang);
|
||||||
|
if (audience) {
|
||||||
|
formData.append("audience", audience);
|
||||||
|
formData.append("user_type", audience); // ensure backend gets correct field
|
||||||
|
}
|
||||||
|
if (html) {
|
||||||
|
const htmlBlob = new Blob([html], { type: "text/html" });
|
||||||
|
formData.append("file", htmlBlob, "template.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
for (const pair of formData.entries()) {
|
||||||
|
log("updateTemplate formData", pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + `/api/document-templates/${id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("updateTemplate response status:", res.status);
|
||||||
|
if (res.ok) {
|
||||||
|
log("updateTemplate success");
|
||||||
|
setUploadMsg("Template erfolgreich bearbeitet.");
|
||||||
|
} else {
|
||||||
|
log("updateTemplate failed");
|
||||||
|
setUploadMsg("Fehler beim Bearbeiten des Templates.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("updateTemplate error:", err);
|
||||||
|
setUploadMsg("Fehler beim Bearbeiten.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PATCH state endpoint
|
||||||
|
const updateTemplateState = async (id, state) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadMsg("");
|
||||||
|
log("updateTemplateState called", { id, state });
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + `/api/document-templates/${id}/state`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("updateTemplateState response status:", res.status);
|
||||||
|
if (res.ok) {
|
||||||
|
log("updateTemplateState success");
|
||||||
|
setUploadMsg("Status erfolgreich geändert.");
|
||||||
|
} else {
|
||||||
|
log("updateTemplateState failed");
|
||||||
|
setUploadMsg("Fehler beim Ändern des Status.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("updateTemplateState error:", err);
|
||||||
|
setUploadMsg("Fehler beim Ändern des Status.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
updateTemplateState,
|
||||||
|
uploading,
|
||||||
|
uploadMsg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useContractDashboard;
|
||||||
971
src/features/admin/contractDashboard/pages/contractDashboard.jsx
Normal file
971
src/features/admin/contractDashboard/pages/contractDashboard.jsx
Normal file
@ -0,0 +1,971 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import CreateContractForm from "../components/createContractForm";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import TemplateStateButton from "../components/TemplateStateButton"; // Import the new button component
|
||||||
|
import { log } from "../../../../utils/logger"; // Add logger import
|
||||||
|
|
||||||
|
const ContractDashboard = () => {
|
||||||
|
const [documents, setDocuments] = useState([]);
|
||||||
|
const [selectedDocId, setSelectedDocId] = useState(null);
|
||||||
|
const [selectedVersions, setSelectedVersions] = useState({}); // per-document selected versions map (legacy)
|
||||||
|
const [selectedGroupVersions, setSelectedGroupVersions] = useState({}); // per-group selected version map
|
||||||
|
const [templateHtml, setTemplateHtml] = useState("");
|
||||||
|
const [templateName, setTemplateName] = useState("");
|
||||||
|
const [templateDesc, setTemplateDesc] = useState("");
|
||||||
|
const [codeView, setCodeView] = useState(false);
|
||||||
|
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||||
|
const [templateError, setTemplateError] = useState("");
|
||||||
|
const [language, setLanguage] = useState("all"); // "de", "en", "all"
|
||||||
|
const [audienceFilter, setAudienceFilter] = useState("all"); // "personal","company","both","all"
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
||||||
|
// --- Firmenstempel State ---
|
||||||
|
const [stampFile, setStampFile] = useState(null);
|
||||||
|
const [stampPreview, setStampPreview] = useState(""); // full data URL
|
||||||
|
const [stampBase64, setStampBase64] = useState(""); // raw base64 (no prefix)
|
||||||
|
const [stampMime, setStampMime] = useState("");
|
||||||
|
const [stampLabel, setStampLabel] = useState("");
|
||||||
|
const [stampUploading, setStampUploading] = useState(false);
|
||||||
|
const [stampError, setStampError] = useState("");
|
||||||
|
const [stampSuccess, setStampSuccess] = useState("");
|
||||||
|
const [stampActivateNow, setStampActivateNow] = useState(true);
|
||||||
|
const [stampList, setStampList] = useState([]); // all stamps
|
||||||
|
const [activeStamp, setActiveStamp] = useState(null); // currently active stamp object
|
||||||
|
const DEBUG_GROUP_LOGS = false; // toggle to see per-render grouping logs
|
||||||
|
|
||||||
|
// Single company stamp logic (company's own stamp always id = 1)
|
||||||
|
const hasCompanyStamp = stampList.some(s => s.id === 1);
|
||||||
|
const companyStamp = stampList.find(s => s.id === 1);
|
||||||
|
|
||||||
|
// Extensive logging for stamp state
|
||||||
|
useEffect(() => {
|
||||||
|
log("STAMP_DEBUG: stampList", stampList);
|
||||||
|
log("STAMP_DEBUG: activeStamp", activeStamp);
|
||||||
|
log("STAMP_DEBUG: hasCompanyStamp", stampList.some(s => s.id === 1), "companyStamp", stampList.find(s => s.id === 1));
|
||||||
|
log("STAMP_DEBUG: derived hasCompanyStamp", hasCompanyStamp, "companyStamp", companyStamp);
|
||||||
|
}, [stampList, activeStamp, hasCompanyStamp, companyStamp]);
|
||||||
|
|
||||||
|
// Fetch all templates from backend
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
setLoadingTemplates(true);
|
||||||
|
setTemplateError("");
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
// Use correct endpoint for admin dashboard
|
||||||
|
const res = await fetch(
|
||||||
|
baseUrl + "/api/document-templates",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: accessToken ? `Bearer ${accessToken}` : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Fehler beim Laden der Templates");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
// log raw API payload for debugging / normalization verification
|
||||||
|
log("fetchTemplates: raw data:", data);
|
||||||
|
// Added: summarize audiences returned
|
||||||
|
const audienceSummary = data.reduce((acc,d)=>{
|
||||||
|
const a = (d.audience || d.user_type || "MISSING/DEFAULT");
|
||||||
|
acc[a] = (acc[a]||0)+1;
|
||||||
|
return acc;
|
||||||
|
},{});
|
||||||
|
log("fetchTemplates: audience summary", audienceSummary);
|
||||||
|
// Normalize: ensure each doc has .audience populated from backend user_type
|
||||||
|
const normalized = data.map(d => ({
|
||||||
|
...d,
|
||||||
|
audience: d.audience || d.user_type || "both",
|
||||||
|
}));
|
||||||
|
setDocuments(normalized);
|
||||||
|
|
||||||
|
// initialize per-group selected versions map
|
||||||
|
const initialGroupSelected = {};
|
||||||
|
const groupsTemp = {};
|
||||||
|
normalized.forEach(doc => {
|
||||||
|
const groupKey = `${doc.name || ""}::${doc.lang || ""}::${doc.type || ""}::${(doc.audience || "both")}`;
|
||||||
|
if (!groupsTemp[groupKey]) groupsTemp[groupKey] = [];
|
||||||
|
groupsTemp[groupKey].push(doc);
|
||||||
|
});
|
||||||
|
Object.keys(groupsTemp).forEach(key => {
|
||||||
|
const docs = groupsTemp[key];
|
||||||
|
// prefer highest version or doc.version
|
||||||
|
const sorted = docs.slice().sort((a,b) => (b.version || 0) - (a.version || 0));
|
||||||
|
initialGroupSelected[key] = sorted[0]?.version || sorted[0]?.version || 1;
|
||||||
|
});
|
||||||
|
log("fetchTemplates: initialGroupSelected:", initialGroupSelected);
|
||||||
|
setSelectedGroupVersions(initialGroupSelected);
|
||||||
|
|
||||||
|
// initialize per-document selected versions map
|
||||||
|
const initialSelected = {};
|
||||||
|
normalized.forEach(doc => {
|
||||||
|
// normalize versions into a flat array (support both shapes)
|
||||||
|
const allVersions = Array.isArray(doc.versions)
|
||||||
|
? doc.versions.slice()
|
||||||
|
: (doc.versions ? Object.values(doc.versions).flat() : []);
|
||||||
|
// prefer a version matching the doc.lang if present
|
||||||
|
const preferred = allVersions.find(v => !v.lang || v.lang === doc.lang);
|
||||||
|
initialSelected[doc.id] = preferred?.version || allVersions[0]?.version || doc.version || 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// log normalized initial selection map
|
||||||
|
log("fetchTemplates: initialSelected (normalized):", initialSelected);
|
||||||
|
|
||||||
|
setSelectedVersions(initialSelected);
|
||||||
|
|
||||||
|
// Select first doc if available
|
||||||
|
if (normalized.length > 0) setSelectedDocId(normalized[0].id);
|
||||||
|
} catch (err) {
|
||||||
|
setTemplateError("Fehler beim Laden der Templates.");
|
||||||
|
console.error("fetchTemplates: error:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firmenstempel: reset selection states
|
||||||
|
const resetStampSelection = () => {
|
||||||
|
setStampFile(null);
|
||||||
|
setStampPreview("");
|
||||||
|
setStampBase64("");
|
||||||
|
setStampMime("");
|
||||||
|
setStampLabel("");
|
||||||
|
setStampActivateNow(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firmenstempel: Validate + read file -> base64
|
||||||
|
const validateAndReadStampFile = (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
setStampError("");
|
||||||
|
setStampSuccess("");
|
||||||
|
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||||
|
if (!allowed.includes(file.type)) {
|
||||||
|
setStampError("Ungültiger Dateityp. Erlaubt: PNG, JPG, WEBP.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxBytes = 500 * 1024; // 500 KB
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
setStampError("Datei zu groß (max. 500 KB).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const dataUrl = e.target.result;
|
||||||
|
if (typeof dataUrl !== "string") {
|
||||||
|
setStampError("Fehler beim Lesen der Datei.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commaIdx = dataUrl.indexOf(",");
|
||||||
|
const raw = commaIdx > -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
|
||||||
|
setStampPreview(dataUrl);
|
||||||
|
setStampBase64(raw);
|
||||||
|
setStampMime(file.type);
|
||||||
|
setStampFile(file);
|
||||||
|
log("stamp file loaded", { size: file.size, type: file.type });
|
||||||
|
};
|
||||||
|
reader.onerror = () => setStampError("Fehler beim Lesen der Datei.");
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const authHeader = () =>
|
||||||
|
accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||||
|
|
||||||
|
// Firmenstempel: Fetch stamps (list + active)
|
||||||
|
const fetchStamps = async () => {
|
||||||
|
log("STAMP_DEBUG: fetchStamps called");
|
||||||
|
try {
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
log("STAMP_DEBUG: fetchStamps base", base);
|
||||||
|
|
||||||
|
const resList = await fetch(base + "/api/company-stamps/mine", {
|
||||||
|
headers: { ...authHeader() },
|
||||||
|
});
|
||||||
|
log("STAMP_DEBUG: resList status", resList.status);
|
||||||
|
let listData = [];
|
||||||
|
if (resList.ok) {
|
||||||
|
listData = await resList.json();
|
||||||
|
log("STAMP_DEBUG: raw listData", listData);
|
||||||
|
// FIX: Accept single object or array
|
||||||
|
if (Array.isArray(listData)) {
|
||||||
|
// ok
|
||||||
|
} else if (listData && typeof listData === "object") {
|
||||||
|
listData = [listData];
|
||||||
|
} else {
|
||||||
|
listData = [];
|
||||||
|
}
|
||||||
|
// normalize: add previewDataUri (backend may already supply it)
|
||||||
|
const normalized = listData.map(s => {
|
||||||
|
log("STAMP_DEBUG: normalizing stamp", s);
|
||||||
|
if (s.previewDataUri) return s;
|
||||||
|
const dataUri = s.image_base64 && s.mime_type
|
||||||
|
? `data:${s.mime_type};base64,${s.image_base64}`
|
||||||
|
: "";
|
||||||
|
return { ...s, previewDataUri: dataUri };
|
||||||
|
});
|
||||||
|
log("STAMP_DEBUG: normalized stampList", normalized);
|
||||||
|
setStampList(normalized);
|
||||||
|
} else {
|
||||||
|
log("STAMP_DEBUG: fetchStamps list failed status", resList.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resActive = await fetch(base + "/api/company-stamps/mine/active", {
|
||||||
|
headers: { ...authHeader() },
|
||||||
|
});
|
||||||
|
log("STAMP_DEBUG: resActive status", resActive.status);
|
||||||
|
if (resActive.ok) {
|
||||||
|
const activeData = await resActive.json();
|
||||||
|
log("STAMP_DEBUG: raw activeData", activeData);
|
||||||
|
setActiveStamp(activeData || null);
|
||||||
|
} else {
|
||||||
|
log("STAMP_DEBUG: fetchStamps active failed status", resActive.status);
|
||||||
|
setActiveStamp(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log("STAMP_DEBUG: fetchStamps error", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firmenstempel: Upload stamp
|
||||||
|
const uploadStamp = async () => {
|
||||||
|
log("STAMP_DEBUG: uploadStamp called", {
|
||||||
|
hasCompanyStamp,
|
||||||
|
companyStamp,
|
||||||
|
stampBase64Len: stampBase64.length,
|
||||||
|
stampMime,
|
||||||
|
stampLabel,
|
||||||
|
});
|
||||||
|
if (hasCompanyStamp) {
|
||||||
|
log("STAMP_DEBUG: blocked upload - company stamp exists", companyStamp);
|
||||||
|
setStampError("Es existiert bereits ein Firmenstempel (ID 1). Lösche ihn zuerst um einen neuen hochzuladen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stampBase64 || !stampMime) {
|
||||||
|
log("STAMP_DEBUG: blocked upload - missing base64 or mime", { stampBase64, stampMime });
|
||||||
|
setStampError("Kein Stempel ausgewählt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStampUploading(true);
|
||||||
|
setStampError("");
|
||||||
|
setStampSuccess("");
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
base64: stampBase64,
|
||||||
|
mimeType: stampMime,
|
||||||
|
label: stampLabel || undefined,
|
||||||
|
activate: !!stampActivateNow,
|
||||||
|
};
|
||||||
|
log("STAMP_DEBUG: uploadStamp payload", body);
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + "/api/company-stamps",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeader(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("STAMP_DEBUG: uploadStamp response status", res.status);
|
||||||
|
if (res.status === 201) {
|
||||||
|
const created = await res.json();
|
||||||
|
log("STAMP_DEBUG: uploadStamp created", created);
|
||||||
|
const withPreview = created.previewDataUri
|
||||||
|
? created
|
||||||
|
: {
|
||||||
|
...created,
|
||||||
|
previewDataUri: created.image_base64 && created.mime_type
|
||||||
|
? `data:${created.mime_type};base64,${created.image_base64}`
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
setStampList([withPreview]);
|
||||||
|
setStampSuccess("Stempel erfolgreich hochgeladen.");
|
||||||
|
resetStampSelection();
|
||||||
|
await fetchStamps();
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
let payload = {};
|
||||||
|
try { payload = await res.json(); } catch {}
|
||||||
|
log("STAMP_DEBUG: uploadStamp 409 payload", payload);
|
||||||
|
const existing = payload.existing || {};
|
||||||
|
const normalizedExisting = existing.previewDataUri
|
||||||
|
? existing
|
||||||
|
: {
|
||||||
|
...existing,
|
||||||
|
previewDataUri: existing.image_base64 && existing.mime_type
|
||||||
|
? `data:${existing.mime_type};base64,${existing.image_base64}`
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
setStampList([normalizedExisting]);
|
||||||
|
setStampError(""); // treat as info
|
||||||
|
setStampSuccess("Vorhandener Firmenstempel geladen (Upload nicht erlaubt).");
|
||||||
|
resetStampSelection();
|
||||||
|
} else {
|
||||||
|
const ct = res.headers.get("content-type") || "";
|
||||||
|
let parsedMsg = "";
|
||||||
|
try {
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
const j = await res.json();
|
||||||
|
parsedMsg = j.message || j.error || (typeof j === "string" ? j : JSON.stringify(j));
|
||||||
|
} else {
|
||||||
|
parsedMsg = (await res.text()) || "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parsedMsg = "Unbekannte Fehlermeldung.";
|
||||||
|
}
|
||||||
|
log("STAMP_DEBUG: uploadStamp server error", { status: res.status, parsedMsg });
|
||||||
|
setStampError(`Upload fehlgeschlagen (HTTP ${res.status}). ${parsedMsg}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log("STAMP_DEBUG: uploadStamp error", e);
|
||||||
|
setStampError("Netzwerkfehler beim Upload.");
|
||||||
|
} finally {
|
||||||
|
setStampUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firmenstempel: Activate stamp
|
||||||
|
const activateStamp = async (id) => {
|
||||||
|
log("STAMP_DEBUG: activateStamp called", { id });
|
||||||
|
if (!id) return;
|
||||||
|
setStampUploading(true);
|
||||||
|
setStampError("");
|
||||||
|
setStampSuccess("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + `/api/company-stamps/${id}/activate`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeader(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("STAMP_DEBUG: activateStamp response status", res.status);
|
||||||
|
if (res.ok) {
|
||||||
|
setStampSuccess("Stempel aktiviert.");
|
||||||
|
await fetchStamps();
|
||||||
|
} else {
|
||||||
|
setStampError("Aktivierung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log("STAMP_DEBUG: activateStamp error", e);
|
||||||
|
setStampError("Netzwerkfehler bei Aktivierung.");
|
||||||
|
} finally {
|
||||||
|
setStampUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firmenstempel: Delete stamp
|
||||||
|
const deleteStamp = async (id) => {
|
||||||
|
log("STAMP_DEBUG: deleteStamp called", { id });
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm("Diesen Stempel wirklich löschen?")) return;
|
||||||
|
setStampUploading(true);
|
||||||
|
setStampError("");
|
||||||
|
setStampSuccess("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
import.meta.env.VITE_API_BASE_URL + `/api/company-stamps/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { ...authHeader() },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("STAMP_DEBUG: deleteStamp response status", res.status);
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
setStampSuccess("Stempel gelöscht.");
|
||||||
|
await fetchStamps();
|
||||||
|
} else {
|
||||||
|
setStampError("Löschen fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log("STAMP_DEBUG: deleteStamp error", e);
|
||||||
|
setStampError("Netzwerkfehler beim Löschen.");
|
||||||
|
} finally {
|
||||||
|
setStampUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch on mount (templates + stamps)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates();
|
||||||
|
fetchStamps();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter documents by language
|
||||||
|
const filteredDocuments = documents.filter(doc => {
|
||||||
|
const langOk = language === "all" || doc.lang === language;
|
||||||
|
const aud = (doc.audience || doc.user_type || "both");
|
||||||
|
const audienceOk = audienceFilter === "all" || aud === audienceFilter;
|
||||||
|
return langOk && audienceOk;
|
||||||
|
});
|
||||||
|
|
||||||
|
// build grouped documents (one list element per logical template)
|
||||||
|
const groupedDocuments = (() => {
|
||||||
|
const map = {};
|
||||||
|
filteredDocuments.forEach(doc => {
|
||||||
|
const key = `${doc.name || ""}::${doc.lang || ""}::${doc.type || ""}::${(doc.audience || "both")}`;
|
||||||
|
if (!map[key]) map[key] = { key, docs: [] };
|
||||||
|
map[key].docs.push(doc);
|
||||||
|
});
|
||||||
|
// sort group's docs by version desc
|
||||||
|
return Object.values(map).map(g => ({ ...g, docs: g.docs.sort((a,b) => (b.version||0) - (a.version||0)) }));
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Find selected document from filtered list
|
||||||
|
const selectedDoc = filteredDocuments.find((doc) => doc.id === selectedDocId);
|
||||||
|
|
||||||
|
// Ensure selected version exists for selectedDoc when doc or language changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDoc) return;
|
||||||
|
const langKey = selectedDoc.lang || (language === "all" ? "de" : language);
|
||||||
|
// normalize to flat array and then filter to the current language (used only for initial selection)
|
||||||
|
const allVersionsForDoc = Array.isArray(selectedDoc.versions)
|
||||||
|
? selectedDoc.versions.slice()
|
||||||
|
: (selectedDoc.versions ? Object.values(selectedDoc.versions).flat() : []);
|
||||||
|
const vList = allVersionsForDoc.filter(v => !v.lang || v.lang === langKey);
|
||||||
|
setSelectedVersions(prev => {
|
||||||
|
const existing = prev[selectedDoc.id];
|
||||||
|
if (existing) return prev;
|
||||||
|
return { ...prev, [selectedDoc.id]: vList[0]?.version || selectedDoc.version || 1 };
|
||||||
|
});
|
||||||
|
}, [selectedDoc, language]);
|
||||||
|
|
||||||
|
// Get versions for selected language for the selectedDoc
|
||||||
|
const langKeyForSelected = selectedDoc
|
||||||
|
? (selectedDoc.lang || (language === "all" ? "de" : language))
|
||||||
|
: (language === "all" ? "de" : language);
|
||||||
|
|
||||||
|
// normalize and filter depending on top-level language filter
|
||||||
|
const versions = selectedDoc ? (() => {
|
||||||
|
const all = Array.isArray(selectedDoc.versions)
|
||||||
|
? selectedDoc.versions.slice()
|
||||||
|
: (selectedDoc.versions ? Object.values(selectedDoc.versions).flat() : []);
|
||||||
|
const filtered = language === "all"
|
||||||
|
? all
|
||||||
|
: all.filter(v => !v.lang || v.lang === langKeyForSelected);
|
||||||
|
// dedupe by version number and sort descending
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = [];
|
||||||
|
for (const v of filtered) {
|
||||||
|
if (!v) continue;
|
||||||
|
const ver = v.version;
|
||||||
|
if (!seen.has(ver)) {
|
||||||
|
seen.add(ver);
|
||||||
|
unique.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unique.sort((a, b) => (b.version || 0) - (a.version || 0));
|
||||||
|
return unique;
|
||||||
|
})() : [];
|
||||||
|
|
||||||
|
// Derive current selected version for the selected document from the map
|
||||||
|
const currentSelectedVersion = selectedDoc ? (selectedVersions[selectedDoc.id] || versions[0]?.version || 1) : null;
|
||||||
|
|
||||||
|
// Find selected version object
|
||||||
|
const versionObj = versions.find((v) => v.version === currentSelectedVersion);
|
||||||
|
|
||||||
|
// Overview counts (adapt for new API shape)
|
||||||
|
const overview = {
|
||||||
|
de: documents.filter(doc => doc.lang === "de").length,
|
||||||
|
en: documents.filter(doc => doc.lang === "en").length,
|
||||||
|
total: documents.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audience label helper (missing before)
|
||||||
|
const audienceLabel = (a) =>
|
||||||
|
a === "personal" ? "Personal" : a === "company" ? "Unternehmen" : "Beide";
|
||||||
|
|
||||||
|
// Rich text editor toolbar actions
|
||||||
|
const execCmd = (cmd, value = null) => {
|
||||||
|
document.execCommand(cmd, false, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save template handler (dummy, just resets fields)
|
||||||
|
const handleSaveTemplate = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Here you would send templateName, templateDesc, templateHtml to backend
|
||||||
|
setTemplateName("");
|
||||||
|
setTemplateDesc("");
|
||||||
|
setTemplateHtml("");
|
||||||
|
setCodeView(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass fetchTemplates to CreateContractForm so it can refresh after upload
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen py-12 px-2">
|
||||||
|
<div className="w-full max-w-[80vw] bg-white rounded-2xl shadow-xl mx-auto p-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-8 text-blue-900">Contract Dashboard</h1>
|
||||||
|
|
||||||
|
{/* Firmenstempel Verwaltung */}
|
||||||
|
<div className="mb-10 border border-blue-100 rounded-xl p-5 bg-blue-50/40">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-blue-800">Firmenstempel</h2>
|
||||||
|
|
||||||
|
{hasCompanyStamp && companyStamp && (
|
||||||
|
<div className="bg-white p-4 rounded border flex items-start gap-4 mb-6">
|
||||||
|
<img
|
||||||
|
src={companyStamp.previewDataUri || `data:${companyStamp.mime_type};base64,${companyStamp.image_base64}`}
|
||||||
|
alt="Firmenstempel"
|
||||||
|
className="h-32 object-contain border rounded bg-gray-50 p-2"
|
||||||
|
/>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-semibold text-blue-900 mb-1">Firmenstempel vorhanden</div>
|
||||||
|
<div className="text-gray-600">Ein weiterer Upload ist nicht erlaubt.</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
ID: {companyStamp.id}{companyStamp.label ? ` | Label: ${companyStamp.label}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteStamp(companyStamp.id)}
|
||||||
|
disabled={stampUploading}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
{activeStamp && activeStamp.id !== companyStamp.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => activateStamp(companyStamp.id)}
|
||||||
|
disabled={stampUploading}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{stampError && <div className="text-xs text-red-600 mt-2">{stampError}</div>}
|
||||||
|
{stampSuccess && <div className="text-xs text-green-600 mt-2">{stampSuccess}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasCompanyStamp && (
|
||||||
|
<>
|
||||||
|
{activeStamp && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-sm font-medium text-green-700">Aktiver Stempel:</div>
|
||||||
|
<img
|
||||||
|
src={`data:${activeStamp.mime_type};base64,${activeStamp.image_base64}`}
|
||||||
|
alt="Aktiver Firmenstempel"
|
||||||
|
className="h-20 mt-2 object-contain border rounded bg-white p-2"
|
||||||
|
/>
|
||||||
|
{activeStamp.label && (
|
||||||
|
<div className="text-xs text-gray-600 mt-1">Label: {activeStamp.label}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 bg-white p-4 rounded border">
|
||||||
|
<label className="text-sm font-medium text-blue-900">
|
||||||
|
Stempel-Datei (PNG / JPG / WEBP, max 500 KB)
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||||
|
className="mt-1 block text-sm"
|
||||||
|
onChange={(e) => validateAndReadStampFile(e.target.files?.[0])}
|
||||||
|
disabled={stampUploading || hasCompanyStamp}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
value={stampLabel}
|
||||||
|
onChange={(e) => setStampLabel(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
disabled={stampUploading || hasCompanyStamp}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-blue-700 font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={stampActivateNow}
|
||||||
|
onChange={(e) => setStampActivateNow(e.target.checked)}
|
||||||
|
disabled={stampUploading || hasCompanyStamp}
|
||||||
|
className="accent-blue-600"
|
||||||
|
/>
|
||||||
|
<span>Direkt aktivieren</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{stampPreview && (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 mb-1">Vorschau:</div>
|
||||||
|
<img
|
||||||
|
src={stampPreview}
|
||||||
|
alt="Firmenstempel Vorschau"
|
||||||
|
className="h-24 object-contain border rounded bg-gray-50 p-2"
|
||||||
|
/>
|
||||||
|
{stampFile && (
|
||||||
|
<div className="text-[11px] text-gray-500 mt-1">
|
||||||
|
{stampFile.name} ({(stampFile.size / 1024).toFixed(1)} KB)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
|
||||||
|
onClick={() => window.open(stampPreview, "_blank")}
|
||||||
|
>
|
||||||
|
Groß öffnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs px-2 py-1 rounded bg-gray-200 hover:bg-gray-300"
|
||||||
|
onClick={resetStampSelection}
|
||||||
|
disabled={stampUploading}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={uploadStamp}
|
||||||
|
disabled={!stampBase64 || stampUploading || hasCompanyStamp}
|
||||||
|
className={`px-3 py-1 rounded text-sm font-semibold text-white ${
|
||||||
|
stampUploading || !stampBase64 || hasCompanyStamp
|
||||||
|
? "bg-gray-400"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stampUploading ? "Lädt..." : "Stempel hochladen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stampError && <div className="text-sm text-red-600">{stampError}</div>}
|
||||||
|
{stampSuccess && <div className="text-sm text-green-600">{stampSuccess}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste aller Stempel (nur falls kein eigener existiert oder zur Übersicht) */}
|
||||||
|
{stampList.length > 0 && !hasCompanyStamp && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 mb-2">Gespeicherte Stempel</h3>
|
||||||
|
<ul className="flex flex-wrap gap-4">
|
||||||
|
{stampList.map((s) => {
|
||||||
|
const dataUrl = `data:${s.mime_type};base64,${s.image_base64}`;
|
||||||
|
const isActive = activeStamp && activeStamp.id === s.id;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className={`border rounded p-3 bg-white w-44 flex flex-col items-center gap-2 ${
|
||||||
|
isActive ? "ring-2 ring-green-400" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={dataUrl}
|
||||||
|
alt={s.label || "Firmenstempel"}
|
||||||
|
className="h-16 object-contain"
|
||||||
|
/>
|
||||||
|
{s.label && (
|
||||||
|
<div className="text-[11px] text-gray-600 truncate w-full text-center">
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col w-full gap-1">
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => activateStamp(s.id)}
|
||||||
|
disabled={stampUploading}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteStamp(s.id)}
|
||||||
|
disabled={stampUploading}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-red-500 text-white hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3 text-blue-700">Dokumenten-Übersicht</h2>
|
||||||
|
{/* Language / Audience filters (restyled & stacked) */}
|
||||||
|
<div className="flex flex-col gap-5 mb-6">
|
||||||
|
{/* Language Filter */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold tracking-wide uppercase text-gray-600 mb-2">
|
||||||
|
Language Filter
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={language === "all"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${language === "all"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
|
||||||
|
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
|
||||||
|
onClick={() => setLanguage("all")}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={language === "de"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${language === "de"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
|
||||||
|
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
|
||||||
|
onClick={() => setLanguage("de")}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={language === "en"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${language === "en"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 focus:ring-blue-500"
|
||||||
|
: "bg-white text-blue-700 border-blue-200 hover:bg-blue-50 focus:ring-blue-400"}`}
|
||||||
|
onClick={() => setLanguage("en")}
|
||||||
|
>
|
||||||
|
Englisch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Type Filter */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold tracking-wide uppercase text-gray-600 mb-2">
|
||||||
|
User Type Filter
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={audienceFilter === "all"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${audienceFilter === "all"
|
||||||
|
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
|
||||||
|
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
|
||||||
|
onClick={() => setAudienceFilter("all")}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={audienceFilter === "personal"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${audienceFilter === "personal"
|
||||||
|
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
|
||||||
|
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
|
||||||
|
onClick={() => setAudienceFilter("personal")}
|
||||||
|
>
|
||||||
|
Personal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={audienceFilter === "company"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${audienceFilter === "company"
|
||||||
|
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
|
||||||
|
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
|
||||||
|
onClick={() => setAudienceFilter("company")}
|
||||||
|
>
|
||||||
|
Unternehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={audienceFilter === "both"}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium shadow-sm border transition
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||||
|
${audienceFilter === "both"
|
||||||
|
? "bg-purple-600 text-white border-purple-600 focus:ring-purple-500"
|
||||||
|
: "bg-white text-purple-700 border-purple-200 hover:bg-purple-50 focus:ring-purple-400"}`}
|
||||||
|
onClick={() => setAudienceFilter("both")}
|
||||||
|
>
|
||||||
|
Beide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loadingTemplates && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 mb-4">
|
||||||
|
<span className="animate-spin h-5 w-5 border-4 border-blue-300 border-t-transparent rounded-full inline-block"></span>
|
||||||
|
<span>Lade Templates...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{templateError && (
|
||||||
|
<div className="text-red-600 mb-4">{templateError}</div>
|
||||||
|
)}
|
||||||
|
<ul className="bg-white rounded shadow p-4">
|
||||||
|
{groupedDocuments.map((group) => {
|
||||||
|
const representative = group.docs[0];
|
||||||
|
// build options from group.docs (each doc is a version)
|
||||||
|
const options = group.docs.map(d => ({ version: d.version || 1, id: d.id, note: d.note || undefined }))
|
||||||
|
// dedupe by version, keep highest-first sort from group
|
||||||
|
.filter((v, idx, arr) => arr.findIndex(x => x.version === v.version) === idx);
|
||||||
|
|
||||||
|
// selected version for this group (fallback to first option)
|
||||||
|
const groupKey = group.key;
|
||||||
|
const selectedVer = selectedGroupVersions[groupKey] || options[0]?.version || representative.version || 1;
|
||||||
|
// find the doc id for this selected version
|
||||||
|
const selectedDocForGroup = group.docs.find(d => (d.version || 1) === selectedVer) || group.docs[0];
|
||||||
|
|
||||||
|
// debug grouping for devtools
|
||||||
|
if (DEBUG_GROUP_LOGS) {
|
||||||
|
log(`group ${group.key}: options`, options, "selectedVer", selectedVer, "selectedDocId", selectedDocForGroup?.id);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={group.key}
|
||||||
|
className={`mb-4 border-b pb-2 last:border-b-0 last:pb-0 cursor-pointer ${selectedDocId === selectedDocForGroup?.id ? "bg-blue-50" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
// select the doc corresponding to the currently chosen version in the group
|
||||||
|
setSelectedDocId(selectedDocForGroup?.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-900">{representative.name}</div>
|
||||||
|
<div className="text-gray-600 text-sm">{representative.description}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Sprache: {representative.lang || "?"} | Typ: {representative.type || "-"} | Zielgruppe: {audienceLabel(representative.audience || representative.user_type || "both")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{group.docs.length > 1 ? `Versions: ${group.docs.map(d => d.version).join(", ")}` : `Version: ${representative.version || "-"}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* group version selector */}
|
||||||
|
<div className="ml-4">
|
||||||
|
<select
|
||||||
|
aria-label={`Version wählen für ${representative.name}`}
|
||||||
|
className="rounded px-2 py-1 text-sm font-medium bg-gray-100 text-gray-800 border border-gray-300 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100 shadow-sm"
|
||||||
|
style={{ minWidth: 110, zIndex: 10 }}
|
||||||
|
value={selectedVer}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ver = Number(e.target.value);
|
||||||
|
setSelectedGroupVersions(prev => ({ ...prev, [groupKey]: ver }));
|
||||||
|
// set selectedDocId to the doc in this group with the chosen version
|
||||||
|
const target = group.docs.find(d => (d.version || 1) === ver) || group.docs[0];
|
||||||
|
if (target) setSelectedDocId(target.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{options.map(v => (
|
||||||
|
<option key={v.version} value={v.version}>
|
||||||
|
v{v.version} {v.note ? `- ${v.note}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State button */}
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* Force remount on id/state change so the button reflects the currently selected version */}
|
||||||
|
<TemplateStateButton
|
||||||
|
key={`tsb-${selectedDocForGroup?.id}-${selectedDocForGroup?.state}`}
|
||||||
|
id={selectedDocForGroup?.id}
|
||||||
|
state={selectedDocForGroup?.state}
|
||||||
|
refreshTemplates={fetchTemplates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredDocuments.length === 0 && (
|
||||||
|
<li className="text-gray-500 text-center py-4">Keine Templates gefunden.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Details Section */}
|
||||||
|
<h2 className="text-xl font-semibold mb-3 text-blue-700">Ausgewähltes Dokument</h2>
|
||||||
|
{selectedDoc && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="bg-gray-50 rounded shadow p-4 min-h-[80px]">
|
||||||
|
<div className="font-semibold mb-2 text-blue-900 bg-white px-2 py-1 rounded inline-block">
|
||||||
|
{selectedDoc.name} ({selectedDoc.lang || "?"})
|
||||||
|
</div>
|
||||||
|
{/* Additional info */}
|
||||||
|
<div className="text-sm text-gray-700 mt-2">
|
||||||
|
<div><b>Typ:</b> {selectedDoc.type || "-"}</div>
|
||||||
|
<div><b>Beschreibung:</b> {selectedDoc.description || "-"}</div>
|
||||||
|
<div><b>Version:</b> {currentSelectedVersion || "-"}</div>
|
||||||
|
<div><b>Erstellt am:</b> {selectedDoc.createdAt ? new Date(selectedDoc.createdAt).toLocaleString() : "-"}</div>
|
||||||
|
<div><b>Letzte Änderung:</b> {selectedDoc.updatedAt ? new Date(selectedDoc.updatedAt).toLocaleString() : "-"}</div>
|
||||||
|
<div><b>ID:</b> {selectedDoc.id}</div>
|
||||||
|
<div><b>Zielgruppe:</b> {audienceLabel(selectedDoc.audience || selectedDoc.user_type || "both")}</div>
|
||||||
|
</div>
|
||||||
|
{/* Contract Preview Dropdown */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-semibold text-blue-700 hover:underline">
|
||||||
|
Vorschau anzeigen
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3">
|
||||||
|
{selectedDoc.previewUrl ? (
|
||||||
|
<iframe
|
||||||
|
src={selectedDoc.previewUrl}
|
||||||
|
title="Contract Preview"
|
||||||
|
className="w-full h-96 border rounded"
|
||||||
|
/>
|
||||||
|
) : selectedDoc.html ? (
|
||||||
|
<div className="border rounded bg-white p-4 mt-2 overflow-auto max-h-[400px]" dangerouslySetInnerHTML={{ __html: selectedDoc.html }} />
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm">Keine Vorschau verfügbar.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{/* Move edit button here */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<CreateContractForm.EditButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template creation form */}
|
||||||
|
<CreateContractForm
|
||||||
|
templateHtml={templateHtml}
|
||||||
|
setTemplateHtml={setTemplateHtml}
|
||||||
|
templateName={templateName}
|
||||||
|
setTemplateName={setTemplateName}
|
||||||
|
codeView={codeView}
|
||||||
|
setCodeView={setCodeView}
|
||||||
|
editorRef={editorRef}
|
||||||
|
handleSaveTemplate={handleSaveTemplate}
|
||||||
|
execCmd={execCmd}
|
||||||
|
refreshTemplates={fetchTemplates}
|
||||||
|
selectedDoc={selectedDoc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractDashboard;
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { authFetch } from "../../../../utils/authFetch";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
// Use VITE_API_BASE_URL directly
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function fetchPermissions(accessToken) {
|
||||||
|
log("fetchPermissions called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/permissions`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchPermissions failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchPermissions success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPermission({ name, description, is_active, created_by }, accessToken) {
|
||||||
|
log("createPermission called", { name, description, is_active, created_by, accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/permissions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name, description, is_active, created_by }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorMsg = "Failed to create permission";
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
errorMsg = data.message || errorMsg;
|
||||||
|
log("createPermission error response:", data);
|
||||||
|
} catch (e) {
|
||||||
|
log("createPermission error parsing response:", e);
|
||||||
|
}
|
||||||
|
log("createPermission failed:", errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("createPermission success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { showToast } from "../../../toast/toastUtils";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import { useCreatePermission } from "../hooks/usePermissionManagement";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function AddPermission({ onPermissionAdded }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { create, loading: isSubmitting, error, success } = useCreatePermission();
|
||||||
|
const { t } = useTranslation('permission_management');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
showToast({ type: "error", message: t('messages.nameRequired') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast({ type: "loading", message: t('messages.creating') });
|
||||||
|
await create({
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim(),
|
||||||
|
is_active: formData.isActive,
|
||||||
|
created_by: user?.id
|
||||||
|
});
|
||||||
|
if (!error && success) {
|
||||||
|
showToast({ type: "success", message: t('messages.createSuccess') });
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
if (onPermissionAdded) {
|
||||||
|
onPermissionAdded();
|
||||||
|
}
|
||||||
|
} else if (error) {
|
||||||
|
showToast({ type: "error", message: error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 mr-4">
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('headings.addPermission')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('form.nameLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={t('form.namePlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black bg-white"
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{t('form.nameHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('form.descriptionLabel')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={t('form.descriptionPlaceholder')}
|
||||||
|
rows="3"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black bg-white"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{t('form.descriptionHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
name="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="ml-2 block text-sm text-gray-700">
|
||||||
|
{t('form.activeLabel')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800">
|
||||||
|
{t('headings.namingGuidelines')}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-blue-700">
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>{t('guidelines.b1')}</li>
|
||||||
|
<li>{t('guidelines.b2')}</li>
|
||||||
|
<li>{t('guidelines.b3')}</li>
|
||||||
|
<li>{t('guidelines.b4')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t('form.reset')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !formData.name.trim()}
|
||||||
|
className={`px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||||
|
isSubmitting || !formData.name.trim()
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
|
||||||
|
{t('form.creating')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('form.submit')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddPermission;
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { showToast } from "../../../toast/toastUtils";
|
||||||
|
import { usePermissions } from "../hooks/usePermissionManagement";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function PermissionOverview({ refreshTrigger, onRefresh }) {
|
||||||
|
const { permissions, loading, error, reload } = usePermissions();
|
||||||
|
const { t } = useTranslation('permission_management');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [refreshTrigger, reload]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!loading && !error) {
|
||||||
|
showToast({ type: "success", message: t('messages.loadSuccess') });
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
showToast({ type: "error", message: error });
|
||||||
|
}
|
||||||
|
}, [loading, error]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-3 text-gray-600">{t('headings.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('headings.currentPermissions')}</h2>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{t('table.total', { count: permissions.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('headings.noPermissionsTitle')}</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{t('headings.noPermissionsDesc')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.name')}</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.description')}</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{permissions.map(p => (
|
||||||
|
<tr key={p.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{p.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-600 max-w-xs">
|
||||||
|
{p.description || t('table.noDescription')}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
p.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{p.is_active ? t('status.active') : t('status.inactive')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionOverview;
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import { fetchPermissions, createPermission } from "../api/permissionManagementApi";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [permissions, setPermissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("usePermissions: loading permissions");
|
||||||
|
fetchPermissions(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("usePermissions: fetched permissions", data);
|
||||||
|
setPermissions(data.permissions || []);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("usePermissions: error loading permissions", err);
|
||||||
|
setError("Failed to load permissions: " + err.message);
|
||||||
|
setPermissions([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { permissions, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePermission() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const create = async ({ name, description, is_active, created_by }) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
log("useCreatePermission: creating permission", { name, description, is_active, created_by });
|
||||||
|
try {
|
||||||
|
await createPermission({ name, description, is_active, created_by }, accessToken);
|
||||||
|
log("useCreatePermission: permission created successfully");
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
log("useCreatePermission: error creating permission", err);
|
||||||
|
setError(err.message || "Failed to create permission");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { create, loading, error, success };
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import PermissionOverview from "../components/PermissionOverview";
|
||||||
|
import AddPermission from "../components/AddPermission";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function PermissionManagement() {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const { t } = useTranslation('permission_management');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={true}>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
<main className="relative z-10 flex justify-center py-2 px-1 sm:px-8 w-full">
|
||||||
|
<div
|
||||||
|
className="rounded-lg shadow-lg p-3 sm:p-8 bg-gray-100"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5%", // Match dashboard/referral management top margin
|
||||||
|
marginBottom: "2%",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "1600px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl sm:text-4xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
|
||||||
|
{t('headings.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
|
||||||
|
{t('headings.subtitle')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
|
||||||
|
<AddPermission onPermissionAdded={() => setRefreshKey((k) => k + 1)} />
|
||||||
|
<PermissionOverview refreshTrigger={refreshKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionManagement;
|
||||||
46
src/features/admin/userManagement/api/adminUserProfileApi.js
Normal file
46
src/features/admin/userManagement/api/adminUserProfileApi.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export async function fetchAdminUserFullData({ id, accessToken }) {
|
||||||
|
log("fetchAdminUserFullData called", { id, accessToken });
|
||||||
|
const res = await fetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/admin/users/${id}/full`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
log("fetchAdminUserFullData error response:", errData);
|
||||||
|
throw new Error(errData.message || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchAdminUserFullData success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminUserDocuments({ id, accessToken }) {
|
||||||
|
log("fetchAdminUserDocuments called", { id, accessToken });
|
||||||
|
const res = await fetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/documents`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchAdminUserDocuments failed with status:", res.status);
|
||||||
|
return { contracts: [], idDocuments: [] };
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchAdminUserDocuments success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
104
src/features/admin/userManagement/api/userManagementApi.js
Normal file
104
src/features/admin/userManagement/api/userManagementApi.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { authFetch } from "../../../../utils/authFetch";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export async function fetchUserList(accessToken) {
|
||||||
|
log("fetchUserList called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-list`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchUserList failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchUserList success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserStats(accessToken) {
|
||||||
|
log("fetchUserStats called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user-stats`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchUserStats failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchUserStats success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserFull(accessToken, id) {
|
||||||
|
// Debug: log the accessToken and headers
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
log("[fetchUserFull] accessToken:", accessToken);
|
||||||
|
log("[fetchUserFull] Request headers:", headers);
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/full`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchUserFull failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchUserFull success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser({ id, accessToken }) {
|
||||||
|
log("[deleteAdminUser] Sending DELETE request", { id, accessToken });
|
||||||
|
const url = `${import.meta.env.VITE_API_BASE_URL}/api/admin/user/${id}`;
|
||||||
|
log("[deleteAdminUser] Request URL:", url);
|
||||||
|
const res = await fetch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log("[deleteAdminUser] Response status:", res.status);
|
||||||
|
let responseBody = null;
|
||||||
|
try {
|
||||||
|
responseBody = await res.clone().json();
|
||||||
|
log("[deleteAdminUser] Response body:", responseBody);
|
||||||
|
} catch (e) {
|
||||||
|
log("[deleteAdminUser] Response body not JSON or empty");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Failed to delete user";
|
||||||
|
if (responseBody && responseBody.message) {
|
||||||
|
msg = responseBody.message;
|
||||||
|
}
|
||||||
|
log("[deleteAdminUser] Error:", msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
log("[deleteAdminUser] Delete successful for user", id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { showToast } from "../../../toast/toastUtils"; // adjust import if needed
|
||||||
|
|
||||||
|
export default function AdminUserActionsSection({
|
||||||
|
userId, // <-- Ensure userId is passed as prop
|
||||||
|
onEditPermissions,
|
||||||
|
onShowStatistics,
|
||||||
|
onExportUserData,
|
||||||
|
onShowLogs
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
|
||||||
|
// Handler for password reset
|
||||||
|
const handlePasswordReset = async () => {
|
||||||
|
if (!userId) {
|
||||||
|
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = sessionStorage.getItem("accessToken"); // <-- use sessionStorage
|
||||||
|
console.log("JWT accessToken for request:", token);
|
||||||
|
if (!token) {
|
||||||
|
showToast({ type: "error", tKey: "toast:unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const requestOptions = {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
// body: JSON.stringify({}) // if you need to send a body, add here
|
||||||
|
};
|
||||||
|
console.log("Sending password reset request:", {
|
||||||
|
url: `${baseUrl}/api/admin/send-password-reset/${userId}`,
|
||||||
|
...requestOptions
|
||||||
|
});
|
||||||
|
const res = await fetch(`${baseUrl}/api/admin/send-password-reset/${userId}`, requestOptions);
|
||||||
|
console.log("Password reset response:", res); // <-- Add this line
|
||||||
|
if (res.ok) {
|
||||||
|
showToast({ type: "success", tKey: "toast:passwordResetSuccess" });
|
||||||
|
} else {
|
||||||
|
// Handle specific errors
|
||||||
|
if (res.status === 401) {
|
||||||
|
showToast({ type: "error", tKey: "toast:unauthorized" });
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
showToast({ type: "error", tKey: "toast:userNotFound" });
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
showToast({ type: "error", tKey: "toast:passwordResetRateLimited" });
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
showToast({ type: "error", tKey: "toast:forbidden" });
|
||||||
|
} else {
|
||||||
|
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ type: "error", tKey: "toast:networkError" });
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-xl shadow p-6 flex flex-col gap-4 mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 4v16m8-8H4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
{t('headings.actions')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span style={{ color: 'gray' }}>working</span>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 border border-blue-200 shadow transition"
|
||||||
|
onClick={handlePasswordReset}
|
||||||
|
type="button"
|
||||||
|
disabled={resetLoading}
|
||||||
|
>
|
||||||
|
{resetLoading ? t('buttons.sendingPasswordReset') : t('buttons.sendPasswordReset')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 border border-purple-200 shadow transition"
|
||||||
|
onClick={onEditPermissions}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.editPermissions')}
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'gray' }}>in development</span>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-green-100 text-green-700 rounded hover:bg-green-200 border border-green-200 shadow transition"
|
||||||
|
onClick={onShowStatistics}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.statistic')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 border border-yellow-200 shadow transition"
|
||||||
|
onClick={onExportUserData}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.exportUserData')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 border border-gray-300 shadow transition"
|
||||||
|
onClick={onShowLogs}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.logs')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tip: For production, use an environment variable for the backend URL instead of hardcoding it.
|
||||||
406
src/features/admin/userManagement/components/AdminUserList.jsx
Normal file
406
src/features/admin/userManagement/components/AdminUserList.jsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import React, { useMemo, useEffect, useState } from "react";
|
||||||
|
import { useUserList, useDeleteAdminUser } from "../hooks/useUserManagement";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DeleteConfirmationModal from "./DeleteConfirmationModal";
|
||||||
|
|
||||||
|
function AdminUserList({ pageSize = 10, filters = {}, onPageSizeChange, refreshKey, onRefresh }) {
|
||||||
|
const { users: allUsers, loading, error, reload } = useUserList();
|
||||||
|
const { deleteUser, loading: deleting, error: deleteError } = useDeleteAdminUser();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
|
||||||
|
// Reset to page 1 when filters or pageSize change
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [filters, pageSize]);
|
||||||
|
|
||||||
|
// Client-side filtering
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
let users = allUsers;
|
||||||
|
if (filters.search) {
|
||||||
|
const search = filters.search.toLowerCase();
|
||||||
|
users = users.filter(
|
||||||
|
(u) =>
|
||||||
|
(u.email && u.email.toLowerCase().includes(search)) ||
|
||||||
|
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
|
||||||
|
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
|
||||||
|
(u.company_name && u.company_name.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.user_type) {
|
||||||
|
users = users.filter((u) => u.user_type === filters.user_type);
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
users = users.filter((u) => u.status === filters.status);
|
||||||
|
}
|
||||||
|
if (filters.role) {
|
||||||
|
users = users.filter((u) => u.role === filters.role);
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}, [allUsers, filters]);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalUsers = filteredUsers.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
|
||||||
|
const pagedUsers = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return filteredUsers.slice(start, start + pageSize);
|
||||||
|
}, [filteredUsers, page, pageSize]);
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
const openDeleteModal = (user) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
const success = await deleteUser(selectedUser.id);
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
if (success) {
|
||||||
|
reload(); // Immediately refetch user list
|
||||||
|
if (onRefresh) onRefresh();
|
||||||
|
} else if (deleteError) {
|
||||||
|
alert(deleteError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('table.allUsers')}</h2>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{loading ? t('misc.loadingUsers') : t('table.showing', { shown: pagedUsers.length, total: totalUsers })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.user')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.type')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.created')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.lastLogin')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : error ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center text-red-500">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : pagedUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center">
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 mx-auto mb-4 text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500 text-lg font-medium">
|
||||||
|
{t('table.noUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t('table.adjustFilters')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pagedUsers.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="hover:bg-gray-50 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{/* Show initials for personal/company */}
|
||||||
|
{user.user_type === "personal"
|
||||||
|
? `${(user.first_name?.[0] || user.email?.[0] || "").toUpperCase()}${(user.last_name?.[0] || user.email?.split("@")[0]?.split(".")[1]?.[0] || "").toUpperCase()}`
|
||||||
|
: (user.company_name
|
||||||
|
? user.company_name
|
||||||
|
.split(" ")
|
||||||
|
.map(w => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()
|
||||||
|
: (user.email?.[0] || "").toUpperCase())
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
|
{user.user_type === "personal"
|
||||||
|
? `${user.first_name ?? ""} ${user.last_name ?? ""}`
|
||||||
|
: user.company_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.user_type === "company"
|
||||||
|
? "bg-purple-100 text-purple-800 border border-purple-200"
|
||||||
|
: "bg-blue-100 text-blue-800 border border-blue-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full mr-2 ${
|
||||||
|
user.user_type === "company"
|
||||||
|
? "bg-purple-400"
|
||||||
|
: "bg-blue-400"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
{user.user_type === "company" ? t('filters.company') : t('filters.personal')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{user.status === "active" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
|
||||||
|
<div className="w-2 h-2 bg-green-400 rounded-full mr-2"></div>
|
||||||
|
{t('filters.active')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user.status === "pending" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||||
|
<div className="w-2 h-2 bg-yellow-400 rounded-full mr-2"></div>
|
||||||
|
{t('filters.pending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user.status === "suspended" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
|
||||||
|
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
|
||||||
|
{t('filters.suspended')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.role === "super_admin"
|
||||||
|
? "bg-red-100 text-red-800 border border-red-200"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-indigo-100 text-indigo-800 border border-indigo-200"
|
||||||
|
: "bg-gray-100 text-gray-800 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full mr-2 ${
|
||||||
|
user.role === "super_admin"
|
||||||
|
? "bg-red-400"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-indigo-400"
|
||||||
|
: "bg-gray-400"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
{t(`filters.${user.role}`)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-900 font-medium">
|
||||||
|
{user.created_at?.slice(0, 10) ?? "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-900">
|
||||||
|
{user.last_login_at ? (
|
||||||
|
<span className="font-medium">
|
||||||
|
{user.last_login_at.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">{t('table.never')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-blue-300 text-xs font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 hover:border-blue-400 transition-colors duration-200"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/admin/user-management/view/${user.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.view')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-yellow-300 text-xs font-medium rounded-md text-yellow-700 bg-yellow-50 hover:bg-yellow-100 hover:border-yellow-400 transition-colors duration-200"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/admin/user-management/profile/${user.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536M9 13l6-6m2 2l-6 6m2 2H7a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 hover:border-red-400 transition-colors duration-200"
|
||||||
|
onClick={() => openDeleteModal(user)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{deleting && selectedUser?.id === user.id ? t('misc.deleting') : t('buttons.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">{t('table.pageXofY', { page, pages: totalPages })}</span>
|
||||||
|
<span className="text-gray-500"> ({t('table.totalUsers', { total: totalUsers })})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
page === 1
|
||||||
|
? "text-gray-400 bg-white cursor-not-allowed"
|
||||||
|
: "text-gray-700 bg-white hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{t('table.previous')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
page === totalPages
|
||||||
|
? "text-gray-400 bg-white cursor-not-allowed"
|
||||||
|
: "text-gray-700 bg-white hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
{t('table.next')}
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 ml-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
open={deleteModalOpen}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => { setDeleteModalOpen(false); setSelectedUser(null); }}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminUserList.defaultProps = {
|
||||||
|
onRefresh: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminUserList;
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function AdminUserListFilterForm({ filters = {}, setFilters, pageSize, setPageSize }) {
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFilters({ ...filters, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFilters({ ...filters }); // Trigger filter (if needed)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
return (
|
||||||
|
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100 mb-8">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('headings.filterTitle')}</h2>
|
||||||
|
<p className="text-gray-600 mt-1">{t('headings.filterSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
name="search"
|
||||||
|
value={filters.search || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
type="text"
|
||||||
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.userType')}</label>
|
||||||
|
<select
|
||||||
|
name="user_type"
|
||||||
|
value={filters.user_type || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allTypes')}</option>
|
||||||
|
<option value="personal">{t('filters.personal')}</option>
|
||||||
|
<option value="company">{t('filters.company')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
value={filters.status || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allStatus')}</option>
|
||||||
|
<option value="active">{t('filters.active')}</option>
|
||||||
|
<option value="pending">{t('filters.pending')}</option>
|
||||||
|
<option value="suspended">{t('filters.suspended')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.role')}</label>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={filters.role || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allRoles')}</option>
|
||||||
|
<option value="user">{t('filters.user')}</option>
|
||||||
|
<option value="admin">{t('filters.admin')}</option>
|
||||||
|
<option value="super_admin">{t('filters.super_admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.filter')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserListFilterForm;
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import useAdminUserProfile from "../hooks/useAdminUserProfile";
|
||||||
|
import AdminUserProfileCard from "./AdminUserProfileCard";
|
||||||
|
import AdminUserMediaSection from "./AdminUserMediaSection";
|
||||||
|
import AdminUserPermissionsSection from "./AdminUserPermissionsSection";
|
||||||
|
import EnlargeMediaModal from "./EnlargeMediaModal";
|
||||||
|
|
||||||
|
function AdminUserListView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
permissions,
|
||||||
|
documents,
|
||||||
|
enlargeDoc,
|
||||||
|
setEnlargeDoc,
|
||||||
|
enlargeDocNavigation,
|
||||||
|
} = useAdminUserProfile();
|
||||||
|
|
||||||
|
// Log backend data for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
console.log("AdminUserListView: user data", user);
|
||||||
|
}
|
||||||
|
if (profile) {
|
||||||
|
console.log("AdminUserListView: profile data", profile);
|
||||||
|
}
|
||||||
|
}, [user, profile]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-blue-600 font-bold text-xl">Loading user...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-red-600 font-bold text-xl">{error || "User not found"}</span>
|
||||||
|
<button
|
||||||
|
className="mt-6 px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
{enlargeDoc && (
|
||||||
|
<EnlargeMediaModal
|
||||||
|
doc={enlargeDoc}
|
||||||
|
onClose={() => setEnlargeDoc(null)}
|
||||||
|
navigation={enlargeDocNavigation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 flex justify-center py-6 sm:py-12 px-1 sm:px-8 w-full">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-2 sm:p-8 w-full"
|
||||||
|
style={{ maxWidth: "1600px", marginTop: "0.5%" }}
|
||||||
|
>
|
||||||
|
<AdminUserProfileCard
|
||||||
|
user={user}
|
||||||
|
profile={profile}
|
||||||
|
infoTitle={
|
||||||
|
(user?.userType || user?.user_type) === "company"
|
||||||
|
? "Company Info"
|
||||||
|
: "Profile Info"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AdminUserMediaSection
|
||||||
|
documents={documents}
|
||||||
|
setEnlargeDoc={setEnlargeDoc}
|
||||||
|
/>
|
||||||
|
<AdminUserPermissionsSection permissions={permissions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserListView;
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import AdminUserList from "./AdminUserList";
|
||||||
|
import AdminUserListFilterForm from "./AdminUserListFilterForm";
|
||||||
|
|
||||||
|
function AdminUserManagementPage() {
|
||||||
|
const [filters, setFilters] = useState({});
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminUserListFilterForm
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
|
<AdminUserList
|
||||||
|
filters={filters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserManagementPage;
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AdminUserMediaSection({ documents, setEnlargeDoc }) {
|
||||||
|
// Helper to truncate filename with ellipsis
|
||||||
|
const truncate = (str, max = 24) =>
|
||||||
|
str && str.length > max ? str.slice(0, max - 3) + "..." : str;
|
||||||
|
|
||||||
|
// Use refs to avoid rendering <a> elements that trigger download on mount
|
||||||
|
const downloadRefs = useRef({});
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-blue-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M3 17l6-6 4 4 5-5" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{t('headings.media')}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
{/* ID Documents */}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
{t('media.idDocuments')}
|
||||||
|
</div>
|
||||||
|
{documents.idDocuments.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{documents.idDocuments.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={
|
||||||
|
(doc.user_id_document_id
|
||||||
|
? `${doc.user_id_document_id}-${doc.side || "unknown"}`
|
||||||
|
: doc.object_storage_id)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-3 bg-white rounded shadow px-3 py-2"
|
||||||
|
style={{ minWidth: 0, maxWidth: "100%" }}
|
||||||
|
>
|
||||||
|
{/* Preview */}
|
||||||
|
{doc.signedUrl && doc.original_filename && (
|
||||||
|
/\.(jpe?g|png|gif|bmp|webp)$/i.test(doc.original_filename) ? (
|
||||||
|
<img
|
||||||
|
src={doc.signedUrl}
|
||||||
|
alt={doc.original_filename}
|
||||||
|
className="w-16 h-16 object-cover rounded border"
|
||||||
|
style={{ minWidth: 48, minHeight: 48 }}
|
||||||
|
/>
|
||||||
|
) : /\.pdf$/i.test(doc.original_filename) ? (
|
||||||
|
<embed
|
||||||
|
src={doc.signedUrl}
|
||||||
|
type="application/pdf"
|
||||||
|
className="w-16 h-16 rounded border bg-gray-50"
|
||||||
|
style={{ minWidth: 48, minHeight: 48 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
|
||||||
|
No Preview
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-semibold text-gray-700 capitalize">
|
||||||
|
{doc.side ? t('media.idSide', { side: doc.side }) : t('media.idGeneric')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-gray-500 text-xs max-w-[100px] sm:max-w-[180px] truncate cursor-pointer"
|
||||||
|
title={doc.original_filename}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncate(doc.original_filename, 24)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||||||
|
onClick={() => setEnlargeDoc(doc)}
|
||||||
|
>
|
||||||
|
{t('buttons.enlarge')}
|
||||||
|
</button>
|
||||||
|
{/* Only show download for images */}
|
||||||
|
{/\.(jpe?g|png|gif|bmp|webp)$/i.test(doc.original_filename) && (
|
||||||
|
<button
|
||||||
|
className="ml-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||||||
|
onClick={() => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = doc.signedUrl;
|
||||||
|
a.download = doc.original_filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.download')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">{t('media.noIdDocuments')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Contracts */}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 7v10a2 2 0 002 2h6a2 2 0 002-2V7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<rect x="7" y="3" width="10" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{t('media.contracts')}
|
||||||
|
</div>
|
||||||
|
{documents.contracts.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{documents.contracts.map((contract) => (
|
||||||
|
<div key={contract.id || contract.object_storage_id} className="flex items-center gap-3 bg-white rounded shadow px-3 py-2" style={{ minWidth: 0, maxWidth: "100%" }}>
|
||||||
|
{/* PDF Preview */}
|
||||||
|
{contract.signedUrl && contract.original_filename && (
|
||||||
|
/\.pdf$/i.test(contract.original_filename) ? (
|
||||||
|
<embed
|
||||||
|
src={contract.signedUrl}
|
||||||
|
type="application/pdf"
|
||||||
|
className="w-16 h-16 rounded border bg-gray-50"
|
||||||
|
style={{ minWidth: 48, minHeight: 48 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
|
||||||
|
No Preview
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-gray-500 text-xs max-w-[100px] sm:max-w-[180px] truncate cursor-pointer"
|
||||||
|
title={contract.original_filename}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncate(contract.original_filename, 24)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||||||
|
onClick={() => setEnlargeDoc(contract)}
|
||||||
|
>
|
||||||
|
{t('buttons.enlarge')}
|
||||||
|
</button>
|
||||||
|
{/* No download button for contracts (PDFs) */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">{t('media.noContracts')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AdminUserPermissionsSection({ permissions }) {
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
return (
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-green-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 12l2 2 4-4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{t('headings.permissions')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2 bg-green-50 border border-green-100 rounded-lg p-4">
|
||||||
|
{Array.isArray(permissions) && permissions.length > 0 ? (
|
||||||
|
permissions.map((perm) => (
|
||||||
|
<span
|
||||||
|
key={perm.id || perm.name || perm}
|
||||||
|
className="inline-block bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium border border-green-200"
|
||||||
|
>
|
||||||
|
{perm.name || perm}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">{t('permissions.none')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,352 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AdminUserProfileCard({ user, profile, permissions = [], infoTitle = "Profile Info" }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['profile.fullName', user.fullName || `${user.firstName || ''} ${user.lastName || ''}`.trim()],
|
||||||
|
['profile.phone', user.phone],
|
||||||
|
['profile.contactPerson', user.contactPersonName],
|
||||||
|
['profile.registrationNumber', user.registrationNumber],
|
||||||
|
['profile.branch', user.branch],
|
||||||
|
['profile.accountHolderName', user.accountHolderName],
|
||||||
|
['profile.iban', user.iban]
|
||||||
|
].filter(r => r[1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 w-full">
|
||||||
|
{/* Back button at top left */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 border border-blue-200 shadow transition"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/admin/user-management")}
|
||||||
|
>
|
||||||
|
{t('buttons.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Avatar/Initials, Name, Email, Type */}
|
||||||
|
<div className="flex flex-col items-center mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white text-2xl font-bold shadow mb-3">
|
||||||
|
{user.user_type === "personal"
|
||||||
|
? `${(profile?.first_name?.[0] || user.email?.[0] || "").toUpperCase()}${(profile?.last_name?.[0] || user.email?.split("@")[0]?.split(".")[1]?.[0] || "").toUpperCase()}`
|
||||||
|
: (profile?.company_name
|
||||||
|
? profile.company_name
|
||||||
|
.split(" ")
|
||||||
|
.map(w => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()
|
||||||
|
: (user.email?.[0] || "").toUpperCase())
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-base sm:text-2xl font-bold text-blue-900 text-center">
|
||||||
|
{user.user_type === "company"
|
||||||
|
? profile?.company_name || user.email
|
||||||
|
: `${profile?.first_name || ""} ${profile?.last_name || ""}`.trim() || user.email}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 break-all text-center text-sm sm:text-lg">{user.email}</p>
|
||||||
|
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.user_type === "company"
|
||||||
|
? "bg-purple-100 text-purple-800 border border-purple-200"
|
||||||
|
: "bg-blue-100 text-blue-800 border border-blue-200"
|
||||||
|
}`}>
|
||||||
|
{user.user_type === "company" ? t('filters.company') : t('filters.personal')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Add space between header and info cards */}
|
||||||
|
<div className="mb-8"></div>
|
||||||
|
{/* User Info & Profile Info as cards with icons */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch">
|
||||||
|
{/* User Info Card */}
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-xl shadow p-6 flex flex-col gap-2 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* User icon */}
|
||||||
|
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{/* Left card: always Profile Info now (personal or company) */}
|
||||||
|
<h2 className="text-xl font-semibold text-blue-900">
|
||||||
|
{t('headings.profileInfo')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="text-gray-700 text-base">
|
||||||
|
{user.user_type === "company" ? (
|
||||||
|
<>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Contact Person Name icon */}
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.contactPersonName')}:</span> {profile?.contact_person_name}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Contact Person Phone icon */}
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.contactPersonPhone')}:</span> {profile?.contact_person_phone}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Created At icon */}
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.createdAt')}:</span> {user.created_at ? new Date(user.created_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Updated At icon */}
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.updatedAt')}:</span> {user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Last Login icon */}
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.lastLogin')}:</span> {user.last_login_at ? new Date(user.last_login_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M16 12a4 4 0 01-8 0V8a4 4 0 018 0v4z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.email')}:</span> {user.email}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.userType')}:</span> {user.user_type}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 17v-6m0 0V7m0 4h4m-4 0H8" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.role')}:</span> {user.role}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Status:</span> {user.status}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.createdAt')}:</span> {user.created_at ? new Date(user.created_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.updatedAt')}:</span> {user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.lastLogin')}:</span> {user.last_login_at ? new Date(user.last_login_at).toLocaleString() : "-"}
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/* Company Info / Profile Info Card */}
|
||||||
|
<div className="bg-purple-50 border border-purple-100 rounded-xl shadow p-6 flex flex-col gap-2 mb-6 w-full h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* Profile icon */}
|
||||||
|
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-semibold text-purple-900">
|
||||||
|
{(user.user_type === "company")
|
||||||
|
? t('headings.companyInfo')
|
||||||
|
: t('headings.accountInfo') /* Right card becomes Account Info for personal */}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="text-gray-700 text-base">
|
||||||
|
{user.user_type === "company" ? (
|
||||||
|
<>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Name icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyName')}:</span> {profile?.company_name}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Email icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<rect x="3" y="8" width="18" height="8" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyEmail')}:</span> {user?.email}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Phone icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyPhone')}:</span> {profile?.phone}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Address icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyAddress')}:</span> {profile?.address}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Postal Code icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyPostalCode')}:</span> {profile?.zip_code}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company City icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyCity')}:</span> {profile?.city}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Country icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyCountry')}:</span> {profile?.country}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Business Type icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyBusinessType')}:</span> {profile?.branch}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Number of Employees icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<text x="12" y="16" textAnchor="middle" fontSize="10" fill="currentColor">#</text>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyEmployees')}:</span> {profile?.number_of_employees}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Company Registration Number icon */}
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyRegistrationNumber')}:</span> {profile?.registration_number}
|
||||||
|
</li>
|
||||||
|
{/* Removed Account Holder Name and IBAN from here */}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.firstName')}:</span> {profile?.first_name}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.lastName')}:</span> {profile?.last_name}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.dateOfBirth')}:</span> {profile?.date_of_birth}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.nationality')}:</span> {profile?.nationality}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.address')}:</span> {profile?.address}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.city')}:</span> {profile?.city}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.country')}:</span> {profile?.country}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.phone')}:</span> {profile?.phone}
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/* Bank Details Card for company users - full width in grid */}
|
||||||
|
{user.user_type === "company" && (
|
||||||
|
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2 mb-6 w-full mt-6 md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* Bank icon */}
|
||||||
|
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M3 10l9-7 9 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-semibold text-green-900">{t('headings.bankDetails')}</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="text-gray-700 text-base">
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* Account Holder Name icon */}
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyAccountHolderName')}:</span> {profile?.account_holder_name}
|
||||||
|
</li>
|
||||||
|
<li className="mb-2 flex items-center gap-2">
|
||||||
|
{/* IBAN icon */}
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('profile.companyIban')}:</span> {user?.iban || profile?.iban}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AdminUserStatistic({ stats }) {
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const Box = ({ label, value }) => (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-blue-600 font-medium">{label}</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-900 mt-1">{value ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold text-gray-800">{t('headings.stats')}</h4>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<Box label={t('stats.totalUsers')} value={stats.totalUsers} />
|
||||||
|
<Box label={t('stats.adminUsers')} value={stats.adminUsers} />
|
||||||
|
<Box label={t('stats.activeUsers')} value={stats.activeUsers} />
|
||||||
|
<Box label={t('stats.personalUsers')} value={stats.personalUsers} />
|
||||||
|
<Box label={t('stats.companyUsers')} value={stats.companyUsers} />
|
||||||
|
<Box label={t('stats.verificationPending')} value={stats.verificationPending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function AdminUserStatus({ stats }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{/* Total Users */}
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Users</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats?.total_users ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Admin Users */}
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Admin Users</p>
|
||||||
|
<p className="text-3xl font-bold text-indigo-600">{stats?.admin_users ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Verification Pending */}
|
||||||
|
<a href="/admin/verify-users" className="block">
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg p-6 hover:bg-orange-50 transition cursor-pointer">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Verification Pending</p>
|
||||||
|
<p className="text-3xl font-bold text-orange-600">{stats?.verification_pending_users ?? 0}</p>
|
||||||
|
<p className="text-xs text-orange-500 mt-1 underline">Click here to verify users</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserStatus;
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function DeleteConfirmationModal({ open, onConfirm, onCancel, user }) {
|
||||||
|
const { t } = useTranslation("user_management");
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-lg font-bold mb-2 text-red-700">
|
||||||
|
{t("deleteModal.title", "Delete User")}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
{t(
|
||||||
|
"deleteModal.confirmText",
|
||||||
|
"Are you sure you want to permanently delete this user and all related data?"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-semibold">{user?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded bg-gray-200 text-gray-700 font-medium"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{t("deleteModal.cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded bg-red-600 text-white font-bold"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{t("deleteModal.delete", "Delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteConfirmationModal;
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Design preserved from original (fade, positioning, arrows, preview).
|
||||||
|
export default function EnlargeMediaModal({ doc, onClose, navigation }) {
|
||||||
|
const { t } = useTranslation('user_management');
|
||||||
|
const { type, arr, idx, set } = navigation || {};
|
||||||
|
const [visible, setVisible] = React.useState(false);
|
||||||
|
|
||||||
|
// Fade in on mount
|
||||||
|
React.useEffect(() => { setVisible(true); }, []);
|
||||||
|
|
||||||
|
// Close with fade-out
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout(() => { onClose && onClose(); }, 200);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Log
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("[EnlargeMediaModal] OPENED");
|
||||||
|
console.log("[EnlargeMediaModal] doc:", doc);
|
||||||
|
console.log("[EnlargeMediaModal] navigation.type:", type);
|
||||||
|
console.log("[EnlargeMediaModal] navigation.idx:", idx);
|
||||||
|
console.log("[EnlargeMediaModal] navigation.arr:", arr);
|
||||||
|
}, [doc, type, idx, arr]);
|
||||||
|
|
||||||
|
// Keyboard navigation (optional)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const key = (e) => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (e.key === 'Escape') handleClose();
|
||||||
|
if (type === "id-image" && arr && arr.length > 1) {
|
||||||
|
if (e.key === 'ArrowLeft' && idx > 0) handlePrev();
|
||||||
|
if (e.key === 'ArrowRight' && idx < arr.length - 1) handleNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', key);
|
||||||
|
return () => window.removeEventListener('keydown', key);
|
||||||
|
}, [visible, idx, arr, type, handleClose]);
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (arr && idx > 0) {
|
||||||
|
set(arr[idx - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleNext = () => {
|
||||||
|
if (arr && idx < arr.length - 1) {
|
||||||
|
set(arr[idx + 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dimension constraints
|
||||||
|
const modalMaxWidth = "90vw";
|
||||||
|
const modalMaxHeight = "92vh";
|
||||||
|
const modalMinWidth = 320;
|
||||||
|
const modalMinHeight = 200;
|
||||||
|
|
||||||
|
// Safe guard
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 flex items-center justify-center transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||||
|
style={{ background: "rgba(120,120,130,0.85)" }}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative bg-white rounded-lg shadow-xl p-6 text-center text-sm text-gray-600 ${visible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{t('media.noMedia','No media available')}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-4 px-4 py-2 text-xs bg-gray-200 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
{t('buttons.close','Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = doc.original_filename || doc.filename || doc.name || '';
|
||||||
|
const isImage = filename ? /\.(jpe?g|png|gif|bmp|webp)$/i.test(filename) : false;
|
||||||
|
const isPdf = filename ? /\.pdf$/i.test(filename) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 flex items-center justify-center transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||||
|
style={{
|
||||||
|
background: "rgba(120,120,130,0.85)",
|
||||||
|
transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
onClick={() => handleClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative bg-white rounded-lg shadow-xl p-4 flex flex-col items-center transition-all duration-200 ${visible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: modalMaxWidth,
|
||||||
|
maxHeight: modalMaxHeight,
|
||||||
|
minWidth: modalMinWidth,
|
||||||
|
minHeight: modalMinHeight,
|
||||||
|
padding: 0,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
className="absolute top-2 right-2 flex items-center justify-center w-9 h-9 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-600 hover:text-red-500 text-xl font-bold shadow transition"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleClose(); }}
|
||||||
|
aria-label={t('buttons.close','Close')}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M6 6l8 8M6 14L14 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Arrows (ID images only) */}
|
||||||
|
{type === "id-image" && arr && arr.length > 1 && (
|
||||||
|
<>
|
||||||
|
{idx > 0 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-9 h-9 flex items-center justify-center text-2xl text-gray-600 shadow"
|
||||||
|
onClick={handlePrev}
|
||||||
|
aria-label="Previous"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{idx < arr.length - 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-9 h-9 flex items-center justify-center text-2xl text-gray-600 shadow"
|
||||||
|
onClick={handleNext}
|
||||||
|
aria-label="Next"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header for ID */}
|
||||||
|
{(type === "id-image" || type === "id-single") && (
|
||||||
|
<div className="mb-1 text-base font-bold text-blue-700 text-center w-full">
|
||||||
|
{doc.side
|
||||||
|
? `${doc.side.charAt(0).toUpperCase() + doc.side.slice(1)} ${t('media.idSide','of ID')}`
|
||||||
|
: t('media.idDocument','ID Document')}
|
||||||
|
{doc.id_type ? ` (${doc.id_type})` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="mb-2 text-gray-700 font-semibold text-center px-4 break-all">
|
||||||
|
{filename}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview container */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-full"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "70vh",
|
||||||
|
minHeight: 120,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<img
|
||||||
|
src={doc.signedUrl}
|
||||||
|
alt={filename}
|
||||||
|
className="max-h-[60vh] max-w-[80vw] rounded border"
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
onLoad={() => console.log("[EnlargeMediaModal] Image loaded:", filename)}
|
||||||
|
/>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={doc.signedUrl}
|
||||||
|
title={filename}
|
||||||
|
className="rounded border bg-gray-50"
|
||||||
|
style={{
|
||||||
|
width: "80vw",
|
||||||
|
height: "65vh",
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
border: "none",
|
||||||
|
background: "#f9fafb"
|
||||||
|
}}
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-lg">
|
||||||
|
{t('media.noMedia','No preview available')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download button (only for images like original) */}
|
||||||
|
{isImage && (
|
||||||
|
<button
|
||||||
|
className="mt-0 mb-4 px-4 py-2 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||||||
|
onClick={() => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = doc.signedUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('buttons.download','Download')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay during fade-out to block clicks (same approach) */}
|
||||||
|
{!visible && (
|
||||||
|
<div className="fixed inset-0 z-50" style={{ pointerEvents: "all" }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.fade-modal { transition: opacity 0.2s; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/features/admin/userManagement/components/PermissionModal.jsx
Normal file
201
src/features/admin/userManagement/components/PermissionModal.jsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
|
||||||
|
const PermissionModal = ({ open, userId, onClose, onSuccess }) => {
|
||||||
|
const [userPermissions, setUserPermissions] = useState([]);
|
||||||
|
const [allPermissions, setAllPermissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [selectedPermIds, setSelectedPermIds] = useState([]);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
const timeout = setTimeout(() => setVisible(false), 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setLoading(true);
|
||||||
|
setMessage("");
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const userPermUrl = `${baseUrl}/api/auth/users/${userId}/permissions`;
|
||||||
|
const allPermUrl = `${baseUrl}/api/permissions`;
|
||||||
|
|
||||||
|
console.log("PermissionModal: Requesting user permissions from", userPermUrl);
|
||||||
|
console.log("PermissionModal: Requesting all permissions from", allPermUrl);
|
||||||
|
console.log("PermissionModal: Using access token:", accessToken);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
axios.get(userPermUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}),
|
||||||
|
axios.get(allPermUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(([userRes, allRes]) => {
|
||||||
|
console.log("PermissionModal: /api/auth/users/:id/permissions response", userRes);
|
||||||
|
console.log("PermissionModal: /api/permissions response", allRes);
|
||||||
|
|
||||||
|
let perms = userRes.data;
|
||||||
|
if (
|
||||||
|
typeof perms === "string" &&
|
||||||
|
perms.startsWith("<!doctype html")
|
||||||
|
) {
|
||||||
|
setMessage(
|
||||||
|
"Fehler: Die Benutzerberechtigungen konnten nicht geladen werden. Die API liefert kein JSON."
|
||||||
|
);
|
||||||
|
setUserPermissions([]);
|
||||||
|
} else {
|
||||||
|
if (!Array.isArray(perms)) perms = Object.values(perms);
|
||||||
|
setUserPermissions(perms);
|
||||||
|
}
|
||||||
|
let allPerms = allRes.data.permissions;
|
||||||
|
if (!Array.isArray(allPerms)) allPerms = [];
|
||||||
|
setAllPermissions(allPerms);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("PermissionModal: Error loading permissions", err);
|
||||||
|
setMessage("Fehler beim Laden der Berechtigungen");
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [open, userId, accessToken]);
|
||||||
|
|
||||||
|
const userPermIds = new Set(
|
||||||
|
Array.isArray(userPermissions) ? userPermissions.map((p) => p.id) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedPermIds(Array.isArray(userPermissions) ? userPermissions.map((p) => p.id) : []);
|
||||||
|
}
|
||||||
|
}, [open, userPermissions]);
|
||||||
|
|
||||||
|
const handleCheckboxChange = (permId) => {
|
||||||
|
setSelectedPermIds((prev) =>
|
||||||
|
prev.includes(permId)
|
||||||
|
? prev.filter((id) => id !== permId)
|
||||||
|
: [...prev, permId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePermissions = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage("");
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const url = `${baseUrl}/api/admin/users/${userId}/permissions`;
|
||||||
|
const selectedPermNames = allPermissions
|
||||||
|
.filter((perm) => selectedPermIds.includes(perm.id))
|
||||||
|
.map((perm) => perm.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.put(
|
||||||
|
url,
|
||||||
|
{ permissions: selectedPermNames },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setMessage("Berechtigungen erfolgreich gespeichert.");
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
setUserPermissions(
|
||||||
|
allPermissions.filter((perm) => selectedPermIds.includes(perm.id))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setMessage("Fehler beim Speichern der Berechtigungen.");
|
||||||
|
console.error("PermissionModal: Error saving permissions", err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 flex items-center justify-center bg-transparent backdrop-blur-sm bg-white/30
|
||||||
|
transition-opacity duration-300 ${open ? "opacity-100" : "opacity-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-8 w-[80vw] max-w-2xl transition-all duration-300 scale-100">
|
||||||
|
<div className="mb-2 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-blue-900 mb-1">Permissions</h1>
|
||||||
|
<h2 className="text-lg font-semibold mb-6 text-gray-700">
|
||||||
|
Berechtigungen des Nutzers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center items-center mb-4">
|
||||||
|
<span className="animate-spin h-6 w-6 mr-2 border-4 border-blue-300 border-t-transparent rounded-full inline-block"></span>
|
||||||
|
<span className="text-blue-600">Laden...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<p className="mb-4 text-red-600 text-center">{message}</p>
|
||||||
|
)}
|
||||||
|
<div className="mb-6 max-h-64 overflow-y-auto">
|
||||||
|
{(!Array.isArray(allPermissions) || allPermissions.length === 0) && !loading && (
|
||||||
|
<p className="text-gray-500 text-center">
|
||||||
|
Keine Berechtigungen vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul>
|
||||||
|
{Array.isArray(allPermissions) && allPermissions.map((perm, idx) => {
|
||||||
|
const checked = selectedPermIds.includes(perm.id);
|
||||||
|
return (
|
||||||
|
<li key={perm.id || idx} className="flex items-center mb-3 px-2 py-1 rounded bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleCheckboxChange(perm.id)}
|
||||||
|
className="mr-3 accent-blue-700 h-5 w-5"
|
||||||
|
/>
|
||||||
|
<span className={`font-medium ${checked ? "text-blue-900" : "text-gray-600"}`}>
|
||||||
|
{perm.name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{perm.description}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout(onClose, 300);
|
||||||
|
}}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded transition font-semibold"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSavePermissions}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded transition font-semibold flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{saving && (
|
||||||
|
<span className="animate-spin h-5 w-5 mr-2 border-4 border-white border-t-transparent rounded-full inline-block"></span>
|
||||||
|
)}
|
||||||
|
Save Permissions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionModal;
|
||||||
117
src/features/admin/userManagement/hooks/useAdminUserProfile.js
Normal file
117
src/features/admin/userManagement/hooks/useAdminUserProfile.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import React from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import {
|
||||||
|
fetchAdminUserFullData,
|
||||||
|
fetchAdminUserDocuments,
|
||||||
|
} from "../api/adminUserProfileApi";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export default function useAdminUserProfile() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
|
||||||
|
const [user, setUser] = React.useState(null);
|
||||||
|
const [personalProfile, setPersonalProfile] = React.useState(null);
|
||||||
|
const [companyProfile, setCompanyProfile] = React.useState(null);
|
||||||
|
const [permissions, setPermissions] = React.useState([]);
|
||||||
|
const [documents, setDocuments] = React.useState({ contracts: [], idDocuments: [] });
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState("");
|
||||||
|
const [enlargeDoc, setEnlargeDoc] = React.useState(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function fetchAll() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useAdminUserProfile: fetching user and documents", { id, accessToken });
|
||||||
|
try {
|
||||||
|
const [userData, docData] = await Promise.all([
|
||||||
|
fetchAdminUserFullData({ id, accessToken }),
|
||||||
|
fetchAdminUserDocuments({ id, accessToken }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
log("useAdminUserProfile: fetched userData", userData);
|
||||||
|
log("useAdminUserProfile: fetched docData", docData);
|
||||||
|
setUser(userData.user);
|
||||||
|
setPersonalProfile(userData.personalProfile || null);
|
||||||
|
setCompanyProfile(userData.companyProfile || null);
|
||||||
|
setPermissions(userData.permissions || []);
|
||||||
|
setDocuments({
|
||||||
|
contracts: docData.contracts || [],
|
||||||
|
idDocuments: docData.idDocuments || [],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
log("useAdminUserProfile: error fetching user or permissions", err);
|
||||||
|
setError("Failed to fetch user or permissions: " + (err.message || err));
|
||||||
|
setUser(null);
|
||||||
|
setPersonalProfile(null);
|
||||||
|
setCompanyProfile(null);
|
||||||
|
setPermissions([]);
|
||||||
|
setDocuments({ contracts: [], idDocuments: [] });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
if (id && accessToken) fetchAll();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [id, accessToken]);
|
||||||
|
|
||||||
|
// Prefer companyProfile for company users, personalProfile for personal users
|
||||||
|
const profile =
|
||||||
|
user && user.user_type === "company" ? companyProfile : personalProfile;
|
||||||
|
|
||||||
|
// Navigation helpers for enlarge modal
|
||||||
|
const getEnlargeDocIndex = () => {
|
||||||
|
if (!enlargeDoc) return { idx: -1, arr: [], type: null };
|
||||||
|
if (enlargeDoc.user_id_document_id) {
|
||||||
|
const isImage = (filename) =>
|
||||||
|
/\.(jpe?g|png|gif|bmp|webp)$/i.test(filename || "");
|
||||||
|
// Always sort images: front first, then back, then others
|
||||||
|
const arr = [...documents.idDocuments]
|
||||||
|
.filter((d) => isImage(d.original_filename))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const order = { front: 0, back: 1 };
|
||||||
|
const aOrder = order[a.side] !== undefined ? order[a.side] : 99;
|
||||||
|
const bOrder = order[b.side] !== undefined ? order[b.side] : 99;
|
||||||
|
return aOrder - bOrder;
|
||||||
|
});
|
||||||
|
// Use both user_id_document_id and side for uniqueness
|
||||||
|
const idx = arr.findIndex(
|
||||||
|
d =>
|
||||||
|
d.user_id_document_id === enlargeDoc.user_id_document_id &&
|
||||||
|
d.side === enlargeDoc.side
|
||||||
|
);
|
||||||
|
const currentIsImage = isImage(enlargeDoc.original_filename);
|
||||||
|
return { idx, arr, type: currentIsImage ? "id-image" : "id-single" };
|
||||||
|
}
|
||||||
|
if (enlargeDoc.id) {
|
||||||
|
const arr = documents.contracts;
|
||||||
|
const idx = arr.findIndex(d => d.id === enlargeDoc.id);
|
||||||
|
return { idx, arr, type: "contract" };
|
||||||
|
}
|
||||||
|
return { idx: -1, arr: [], type: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { idx: enlargeIdx, arr: enlargeArr, type: enlargeType } = getEnlargeDocIndex();
|
||||||
|
|
||||||
|
const enlargeDocNavigation = {
|
||||||
|
type: enlargeType,
|
||||||
|
arr: enlargeArr,
|
||||||
|
idx: enlargeIdx,
|
||||||
|
set: setEnlargeDoc,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
permissions,
|
||||||
|
documents,
|
||||||
|
enlargeDoc,
|
||||||
|
setEnlargeDoc,
|
||||||
|
enlargeDocNavigation,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
src/features/admin/userManagement/hooks/useUserManagement.js
Normal file
124
src/features/admin/userManagement/hooks/useUserManagement.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import {
|
||||||
|
fetchUserList,
|
||||||
|
fetchUserStats,
|
||||||
|
fetchUserFull,
|
||||||
|
deleteAdminUser,
|
||||||
|
} from "../api/userManagementApi";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export function useUserList() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useUserList: loading users");
|
||||||
|
fetchUserList(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useUserList: fetched users", data);
|
||||||
|
setUsers(data.users || []);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useUserList: error loading users", err);
|
||||||
|
setError("Failed to load users: " + err.message);
|
||||||
|
setUsers([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { users, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserStats() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useUserStats: loading stats");
|
||||||
|
fetchUserStats(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useUserStats: fetched stats", data);
|
||||||
|
setStats(data.stats);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useUserStats: error loading stats", err);
|
||||||
|
setError("Failed to load statistics: " + err.message);
|
||||||
|
setStats(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { stats, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserFull(id) {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [userData, setUserData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useUserFull: fetching user full data for id", id);
|
||||||
|
fetchUserFull(accessToken, id)
|
||||||
|
.then((data) => {
|
||||||
|
log("useUserFull: fetched user data", data);
|
||||||
|
setUserData(data);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useUserFull: error fetching user data", err);
|
||||||
|
setError("Failed to fetch user data: " + err.message);
|
||||||
|
setUserData(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken, id]);
|
||||||
|
|
||||||
|
return { userData, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAdminUser() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const deleteUser = useCallback(
|
||||||
|
async (id) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await deleteAdminUser({ id, accessToken });
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to delete user");
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accessToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { deleteUser, loading, error };
|
||||||
|
}
|
||||||
158
src/features/admin/userManagement/pages/AdminUserProfilePage.jsx
Normal file
158
src/features/admin/userManagement/pages/AdminUserProfilePage.jsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import useAdminUserProfile from "../hooks/useAdminUserProfile";
|
||||||
|
import AdminUserProfileCard from "../components/AdminUserProfileCard";
|
||||||
|
import AdminUserMediaSection from "../components/AdminUserMediaSection";
|
||||||
|
import AdminUserPermissionsSection from "../components/AdminUserPermissionsSection";
|
||||||
|
import EnlargeMediaModal from "../components/EnlargeMediaModal";
|
||||||
|
import AdminUserActionsSection from "../components/AdminUserActionsSection";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import PermissionModal from "../components/PermissionModal";
|
||||||
|
|
||||||
|
function AdminUserProfilePage() {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
permissions,
|
||||||
|
documents,
|
||||||
|
enlargeDoc,
|
||||||
|
setEnlargeDoc,
|
||||||
|
enlargeDocNavigation,
|
||||||
|
} = useAdminUserProfile();
|
||||||
|
const { t } = useTranslation("user_management");
|
||||||
|
|
||||||
|
// Log backend data for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
console.log("AdminUserProfilePage: user data", user);
|
||||||
|
}
|
||||||
|
if (profile) {
|
||||||
|
console.log("AdminUserProfilePage: profile data", profile);
|
||||||
|
}
|
||||||
|
}, [user, profile]);
|
||||||
|
|
||||||
|
const [permissionModalOpen, setPermissionModalOpen] = React.useState(false);
|
||||||
|
const [permissionsState, setPermissionsState] = React.useState(permissions);
|
||||||
|
|
||||||
|
// Refresh permissions after modal update
|
||||||
|
const refreshPermissions = async () => {
|
||||||
|
try {
|
||||||
|
// Replace with your actual API endpoint and authentication as needed
|
||||||
|
const res = await fetch(`/api/users/${user.id}/permissions`);
|
||||||
|
const updated = await res.json();
|
||||||
|
setPermissionsState(updated);
|
||||||
|
} catch {
|
||||||
|
// Optionally handle error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-blue-600 font-bold text-xl">
|
||||||
|
{t("headings.profileViewLoading")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-red-600 font-bold text-xl">
|
||||||
|
{error || t("headings.userNotFound")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace these alert handlers with real implementations if you want requests sent
|
||||||
|
const handleEditPermissions = () => {
|
||||||
|
setPermissionModalOpen(true);
|
||||||
|
};
|
||||||
|
const handleShowStatistics = () => alert("Show statistics (not implemented)");
|
||||||
|
const handleExportUserData = () => alert("Export user data (not implemented)");
|
||||||
|
const handleShowLogs = () => alert("Show logs (not implemented)");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
{enlargeDoc && (
|
||||||
|
<EnlargeMediaModal
|
||||||
|
doc={enlargeDoc}
|
||||||
|
onClose={() => setEnlargeDoc(null)}
|
||||||
|
navigation={enlargeDocNavigation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Permission Modal */}
|
||||||
|
<PermissionModal
|
||||||
|
open={permissionModalOpen}
|
||||||
|
userId={user.id}
|
||||||
|
onClose={() => setPermissionModalOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
refreshPermissions();
|
||||||
|
setPermissionModalOpen(false);
|
||||||
|
}}
|
||||||
|
currentPermissions={permissionsState}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex justify-center py-6 sm:py-12 px-1 sm:px-8 w-full">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-2 sm:p-8 w-full"
|
||||||
|
style={{ maxWidth: "1600px", marginTop: "0.5%" }}
|
||||||
|
>
|
||||||
|
{/* Header (removed upper gray back button) */}
|
||||||
|
<h2 className="text-xl font-bold text-blue-900 mb-6">
|
||||||
|
{t('headings.main', 'User Management')} / {user.email}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Single column order: Profile -> Media -> Permissions -> Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AdminUserProfileCard
|
||||||
|
user={user}
|
||||||
|
profile={profile}
|
||||||
|
infoTitle={
|
||||||
|
(user?.userType || user?.user_type) === "company"
|
||||||
|
? t('headings.companyInfo')
|
||||||
|
: t('headings.profileInfo')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Media moved under profile info */}
|
||||||
|
<AdminUserMediaSection
|
||||||
|
documents={documents}
|
||||||
|
setEnlargeDoc={setEnlargeDoc}
|
||||||
|
media={documents}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminUserPermissionsSection permissions={permissionsState} />
|
||||||
|
|
||||||
|
<AdminUserActionsSection
|
||||||
|
userId={user.id}
|
||||||
|
onEditPermissions={handleEditPermissions}
|
||||||
|
onShowStatistics={handleShowStatistics}
|
||||||
|
onExportUserData={handleExportUserData}
|
||||||
|
onShowLogs={handleShowLogs}
|
||||||
|
// No password reset handler prop passed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminUserProfilePage;
|
||||||
62
src/features/admin/userManagement/pages/UserManagement.jsx
Normal file
62
src/features/admin/userManagement/pages/UserManagement.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import AdminUserStatistic from "../components/AdminUserStatistic";
|
||||||
|
import AdminUserListFilterForm from "../components/AdminUserListFilterForm";
|
||||||
|
import AdminUserList from "../components/AdminUserList";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
|
||||||
|
function UserManagement() {
|
||||||
|
const [filters, setFilters] = useState({});
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const { t } = useTranslation("user_management");
|
||||||
|
|
||||||
|
const handlePageSizeChange = useCallback((size) => setPageSize(size), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={true}>
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="relative z-10 w-full flex justify-center">
|
||||||
|
<div
|
||||||
|
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5%", // Match dashboard/referral management top margin
|
||||||
|
marginBottom: "2%",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "1600px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl sm:text-5xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
|
||||||
|
{t("headings.main")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
|
||||||
|
{t("headings.subtitle")}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
|
||||||
|
<AdminUserStatistic />
|
||||||
|
<AdminUserListFilterForm
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
|
<AdminUserList
|
||||||
|
filters={filters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserManagement;
|
||||||
|
|
||||||
68
src/features/admin/verifyUser/api/verifyUserApi.js
Normal file
68
src/features/admin/verifyUser/api/verifyUserApi.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { authFetch } from "../../../../utils/authFetch";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export async function fetchVerificationPendingUsers(accessToken) {
|
||||||
|
log("fetchVerificationPendingUsers called", { accessToken });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/verification-pending-users`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchVerificationPendingUsers failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchVerificationPendingUsers success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVerifyUserFull(accessToken, id) {
|
||||||
|
log("fetchVerifyUserFull called", { accessToken, id });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/users/${id}/full`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchVerifyUserFull failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchVerifyUserFull success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVerifyUserDocuments(accessToken, id) {
|
||||||
|
log("fetchVerifyUserDocuments called", { accessToken, id });
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/user/${id}/documents`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchVerifyUserDocuments failed with status:", res.status);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchVerifyUserDocuments success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
499
src/features/admin/verifyUser/components/VerifyUserDocuments.jsx
Normal file
499
src/features/admin/verifyUser/components/VerifyUserDocuments.jsx
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Extract extension from original_filename or object_storage_id
|
||||||
|
function getFileExtension(doc) {
|
||||||
|
const filename = doc?.original_filename || doc?.object_storage_id || "";
|
||||||
|
const match = filename.match(/\.([a-zA-Z0-9]+)$/);
|
||||||
|
return match ? match[1].toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(doc) {
|
||||||
|
const ext = getFileExtension(doc);
|
||||||
|
return ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(doc) {
|
||||||
|
const ext = getFileExtension(doc);
|
||||||
|
return ext === "pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifyUserDocuments({ docsData }) {
|
||||||
|
const [enlargeDoc, setEnlargeDoc] = useState(null);
|
||||||
|
const [enlargeType, setEnlargeType] = useState(null); // "id" or "contract"
|
||||||
|
const [enlargeIdx, setEnlargeIdx] = useState(null);
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[VerifyUserDocuments] docsData:", docsData);
|
||||||
|
if (docsData && docsData.documents) {
|
||||||
|
docsData.documents.forEach((doc, idx) => {
|
||||||
|
console.log(`[VerifyUserDocuments] Document[${idx}]:`, doc);
|
||||||
|
console.log(`[VerifyUserDocuments] Document[${idx}] signedUrl:`, doc.signedUrl);
|
||||||
|
console.log(`[VerifyUserDocuments] Document[${idx}] original_filename:`, doc.original_filename);
|
||||||
|
console.log(`[VerifyUserDocuments] Document[${idx}] type:`, getFileExtension(doc));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [docsData]);
|
||||||
|
|
||||||
|
if (!docsData) {
|
||||||
|
console.log("[VerifyUserDocuments] No docsData provided");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const documents = docsData.documents || [];
|
||||||
|
|
||||||
|
// Split ID documents and contracts
|
||||||
|
const idDocs = documents.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.document_type?.toLowerCase().includes("id") ||
|
||||||
|
doc.document_type?.toLowerCase().includes("personal_id") ||
|
||||||
|
doc.document_type?.toLowerCase().includes("company_id")
|
||||||
|
);
|
||||||
|
const contracts = documents.filter(
|
||||||
|
(doc) => doc.document_type?.toLowerCase() === "contract"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Order ID docs: front first, then back
|
||||||
|
const orderedIdDocs = [...idDocs].sort((a, b) => {
|
||||||
|
const order = { front: 0, back: 1 };
|
||||||
|
const aOrder = order[a.side] !== undefined ? order[a.side] : 99;
|
||||||
|
const bOrder = order[b.side] !== undefined ? order[b.side] : 99;
|
||||||
|
return aOrder - bOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal navigation helpers for ID docs
|
||||||
|
const handleEnlargeId = (idx) => {
|
||||||
|
setEnlargeDoc(orderedIdDocs[idx]);
|
||||||
|
setEnlargeType("id");
|
||||||
|
setEnlargeIdx(idx);
|
||||||
|
};
|
||||||
|
const handleEnlargeContract = (idx) => {
|
||||||
|
setEnlargeDoc(contracts[idx]);
|
||||||
|
setEnlargeType("contract");
|
||||||
|
setEnlargeIdx(idx);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setEnlargeDoc(null);
|
||||||
|
setEnlargeType(null);
|
||||||
|
setEnlargeIdx(null);
|
||||||
|
};
|
||||||
|
const handlePrev = () => setEnlargeIdx((idx) => {
|
||||||
|
if (enlargeType === "id" && idx > 0) {
|
||||||
|
setEnlargeDoc(orderedIdDocs[idx - 1]);
|
||||||
|
return idx - 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
});
|
||||||
|
const handleNext = () => setEnlargeIdx((idx) => {
|
||||||
|
if (enlargeType === "id" && idx < orderedIdDocs.length - 1) {
|
||||||
|
setEnlargeDoc(orderedIdDocs[idx + 1]);
|
||||||
|
return idx + 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-10">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-blue-400 mr-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M3 17l6-6 4 4 5-5" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{t('headings.media')}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 flex flex-col gap-8 text-lg">
|
||||||
|
{/* ID Documents */}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
{t('headings.idDocuments')}
|
||||||
|
</div>
|
||||||
|
{orderedIdDocs.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
{orderedIdDocs.map((doc, idx) => (
|
||||||
|
<div
|
||||||
|
key={doc.object_storage_id || doc.original_filename}
|
||||||
|
className="flex w-full items-center gap-4 bg-white rounded shadow px-4 py-3"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{/* Preview */}
|
||||||
|
{doc.signedUrl && doc.original_filename && isImage(doc) ? (
|
||||||
|
<img
|
||||||
|
src={doc.signedUrl}
|
||||||
|
alt={doc.original_filename}
|
||||||
|
className="w-16 h-16 object-cover rounded border"
|
||||||
|
style={{ minWidth: 48, minHeight: 48 }}
|
||||||
|
onClick={() => handleEnlargeId(idx)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
|
||||||
|
No Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-lg font-semibold text-gray-700 capitalize">{doc.side || "ID"}</span>
|
||||||
|
<span
|
||||||
|
className="text-gray-500 text-lg truncate cursor-pointer flex-1 min-w-0"
|
||||||
|
title={doc.original_filename}
|
||||||
|
>
|
||||||
|
{doc.original_filename}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||||||
|
onClick={() => handleEnlargeId(idx)}
|
||||||
|
>
|
||||||
|
{t('buttons.enlarge')}
|
||||||
|
</button>
|
||||||
|
{/* Only show download for images */}
|
||||||
|
{/* REMOVE download button for verify user */}
|
||||||
|
{/* {isImage(doc) && (
|
||||||
|
<button
|
||||||
|
className="ml-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||||||
|
onClick={...}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-lg">{t('headings.noIdDocs')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Contracts */}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 7v10a2 2 0 002 2h6a2 2 0 002-2V7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<rect x="7" y="3" width="10" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{t('headings.contracts')}
|
||||||
|
</div>
|
||||||
|
{contracts.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
{contracts.map((doc, idx) => (
|
||||||
|
<div
|
||||||
|
key={doc.object_storage_id || doc.original_filename}
|
||||||
|
className="flex w-full items-center gap-4 bg-white rounded shadow px-4 py-3"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{/* PDF Preview */}
|
||||||
|
{doc.signedUrl && doc.original_filename && isPdf(doc) ? (
|
||||||
|
<>
|
||||||
|
{console.log("[VerifyUserDocuments] Rendering PDF preview for:", doc.original_filename, doc.signedUrl)}
|
||||||
|
<embed
|
||||||
|
src={doc.signedUrl}
|
||||||
|
type="application/pdf"
|
||||||
|
className="w-16 h-16 rounded border bg-gray-50"
|
||||||
|
style={{ minWidth: 48, minHeight: 48 }}
|
||||||
|
onClick={() => handleEnlargeContract(idx)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="w-16 h-16 flex items-center justify-center bg-gray-100 text-gray-400 rounded border text-xs">
|
||||||
|
No Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-gray-500 text-lg truncate cursor-pointer flex-1 min-w-0"
|
||||||
|
title={doc.original_filename}
|
||||||
|
>
|
||||||
|
{doc.original_filename}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||||||
|
onClick={() => handleEnlargeContract(idx)}
|
||||||
|
>
|
||||||
|
{t('buttons.enlarge')}
|
||||||
|
</button>
|
||||||
|
{/* No download button for contracts */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-lg">{t('headings.noContracts')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Enlarged Modal for ID Documents and Contracts */}
|
||||||
|
{enlargeDoc && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.2)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative bg-white rounded-xl shadow-2xl flex flex-col items-center"
|
||||||
|
style={{
|
||||||
|
width: "90vw",
|
||||||
|
maxWidth: "1200px",
|
||||||
|
minWidth: "340px",
|
||||||
|
padding: "2.5rem",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-600 hover:text-red-500 text-xl font-bold shadow transition"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M6 6l8 8M6 14L14 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Navigation arrows for ID images only */}
|
||||||
|
{enlargeType === "id" && orderedIdDocs.length > 1 && (
|
||||||
|
<div className="absolute left-0 right-0 top-1/2 flex justify-between px-10 -translate-y-1/2">
|
||||||
|
{/* Left arrow: only if idx > 0, else render empty space */}
|
||||||
|
<div style={{ width: 40, height: 40 }}>
|
||||||
|
{enlargeIdx > 0 ? (
|
||||||
|
<button
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
|
||||||
|
aria-label="Previous"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 40, height: 40 }}>
|
||||||
|
{enlargeIdx < orderedIdDocs.length - 1 ? (
|
||||||
|
<button
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleNext(); }}
|
||||||
|
aria-label="Next"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Header for ID document */}
|
||||||
|
{enlargeType === "id" && (
|
||||||
|
<div className="mb-1 text-2xl font-bold text-blue-700 text-center w-full">
|
||||||
|
{enlargeDoc.side
|
||||||
|
? `${enlargeDoc.side.charAt(0).toUpperCase() + enlargeDoc.side.slice(1)} ${t('headings.ofId')}`
|
||||||
|
: "ID Document"}
|
||||||
|
{enlargeDoc.id_type ? ` (${enlargeDoc.id_type})` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enlargeType === "contract" && (
|
||||||
|
<div className="mb-1 text-2xl font-bold text-blue-700 text-center w-full">
|
||||||
|
{t('headings.contracts')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-2 text-gray-700 font-semibold text-center text-xl">
|
||||||
|
{enlargeDoc.original_filename}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-full"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
minHeight: 120,
|
||||||
|
minWidth: 0,
|
||||||
|
marginBottom: 24,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image preview with zoom and navigation */}
|
||||||
|
{enlargeType === "id" && isImage(enlargeDoc) ? (
|
||||||
|
<>
|
||||||
|
{/* Navigation arrows for ID images only, always at far left/right, outside content */}
|
||||||
|
{orderedIdDocs.length > 1 && (
|
||||||
|
<>
|
||||||
|
{enlargeIdx > 0 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-[-60px] top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow pointer-events-auto"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handlePrev(); }}
|
||||||
|
aria-label="Previous"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{enlargeIdx < orderedIdDocs.length - 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-[-60px] top-1/2 -translate-y-1/2 bg-gray-200 hover:bg-gray-300 rounded-full w-10 h-10 flex items-center justify-center text-2xl text-gray-600 shadow pointer-events-auto"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleNext(); }}
|
||||||
|
aria-label="Next"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ZoomableImage src={enlargeDoc.signedUrl} alt={enlargeDoc.original_filename} />
|
||||||
|
</>
|
||||||
|
) : enlargeType === "contract" && isPdf(enlargeDoc) ? (
|
||||||
|
// FIX: Use <embed> for PDF preview, not <iframe>
|
||||||
|
<embed
|
||||||
|
src={enlargeDoc.signedUrl}
|
||||||
|
type="application/pdf"
|
||||||
|
className="rounded border bg-gray-50"
|
||||||
|
style={{
|
||||||
|
width: "90vw",
|
||||||
|
height: "75vh",
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
border: "none",
|
||||||
|
background: "#f9fafb"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xl">{t('headings.noPreview')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoomableImage component for modal
|
||||||
|
function ZoomableImage({ src, alt }) {
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [start, setStart] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const imgRef = React.useRef();
|
||||||
|
|
||||||
|
// Mouse wheel zoom
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setZoom((z) => Math.max(1, Math.min(4, z + (e.deltaY < 0 ? 0.2 : -0.2))));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse drag for panning
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
setStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => setDragging(false);
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (dragging) {
|
||||||
|
setOffset({
|
||||||
|
x: e.clientX - start.x,
|
||||||
|
y: e.clientY - start.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch events for mobile
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
setDragging(true);
|
||||||
|
setStart({
|
||||||
|
x: e.touches[0].clientX - offset.x,
|
||||||
|
y: e.touches[0].clientY - offset.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleTouchEnd = () => setDragging(false);
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
if (dragging && e.touches.length === 1) {
|
||||||
|
setOffset({
|
||||||
|
x: e.touches[0].clientX - start.x,
|
||||||
|
y: e.touches[0].clientY - start.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset zoom/pan on image change
|
||||||
|
React.useEffect(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "70vh",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#f9fafb",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
}}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoom}) translate(${offset.x / zoom}px, ${offset.y / zoom}px)`,
|
||||||
|
transition: dragging ? "none" : "transform 0.2s",
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
cursor: zoom > 1 ? "grab" : "zoom-in",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Lupe/zoom controls */}
|
||||||
|
<div className="absolute bottom-4 right-4 flex gap-2 bg-white/80 rounded-lg shadow p-2">
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-blue-100 text-blue-700 font-bold text-lg hover:bg-blue-200"
|
||||||
|
onClick={() => setZoom((z) => Math.max(1, z - 0.2))}
|
||||||
|
disabled={zoom <= 1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="px-2 font-semibold text-blue-900">{Math.round(zoom * 100)}%</span>
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-blue-100 text-blue-700 font-bold text-lg hover:bg-blue-200"
|
||||||
|
onClick={() => setZoom((z) => Math.min(4, z + 0.2))}
|
||||||
|
disabled={zoom >= 4}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-gray-100 text-gray-700 font-bold text-lg hover:bg-gray-200"
|
||||||
|
onClick={() => { setZoom(1); setOffset({ x: 0, y: 0 }); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserDocuments;
|
||||||
@ -0,0 +1,613 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Helper to format label
|
||||||
|
function formatLabel(label) {
|
||||||
|
return label
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format value (dates, booleans, etc.)
|
||||||
|
function formatValue(value) {
|
||||||
|
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "Yes" : "No";
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon mapping helpers
|
||||||
|
const userInfoIcons = {
|
||||||
|
"Contact Person Name": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Contact Person Phone": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Created At": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Updated At": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Last Login": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 8v4l3 3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"First Name": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="8" r="4"/>
|
||||||
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Lastname": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<circle cx="9" cy="8" r="4"/>
|
||||||
|
<path d="M2 20c0-3.5 3.5-6 7-6"/>
|
||||||
|
<circle cx="18" cy="10" r="3"/>
|
||||||
|
<path d="M18 13c2.5 0 4 1.5 4 3"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Full Name": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<path d="M12 12c2.5 0 4.5-2 4.5-4.5S14.5 3 12 3 7.5 5 7.5 7.5 9.5 12 12 12z"/>
|
||||||
|
<path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Phone": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4c0 .6-.4 1-1 1A16 16 0 014 5c0-.6.4-1 1-1z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Email": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<path d="M3 7l9 6 9-6"/>
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Date Of Birth": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<rect x="4" y="5" width="16" height="16" rx="2"/>
|
||||||
|
<path d="M8 3v4M16 3v4M4 11h16"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Referrer Email": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<path d="M4 4h9v6H4z"/>
|
||||||
|
<path d="M13 7l7 3-7 3v-2H8V9h5V7z"/>
|
||||||
|
<path d="M4 14h9v6H4z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Last Login At": (
|
||||||
|
<svg className="w-4 h-4 text-blue-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 7v5l3 3"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const companyInfoIcons = {
|
||||||
|
"Company Name": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Email": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<rect x="3" y="8" width="18" height="8" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Phone": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M22 16.92V19a2 2 0 01-2 2H4a2 2 0 01-2-2v-2.08a2 2 0 01.84-1.63l8-5.33a2 2 0 012.32 0l8 5.33a2 2 0 01.84 1.63z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Address": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 10v10a1 1 0 001 1h16a1 1 0 001-1V10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M7 10V7a5 5 0 0110 0v3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Postal Code": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company City": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Country": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Business Type": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Number of Employees": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
{/* # symbol not supported in SVG, so just circle */}
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Registration Number": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Nationality": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Address": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none">
|
||||||
|
<path d="M12 21s7-6.2 7-11.5S15.9 3 12 3 5 5.6 5 9.5 12 21 12 21z"/>
|
||||||
|
<circle cx="12" cy="9.5" r="2.5"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Zip Code": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||||
|
<path d="M8 8h8v8H8z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"City": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 21V10l6-3 6 3 6-3v11"/>
|
||||||
|
<path d="M9 21V12M15 21V12"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Country": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 3h6l2 3h6v13H5z"/>
|
||||||
|
<path d="M5 9h14"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Phone Secondary": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Emergency Contact Name": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="9" cy="8" r="4"/>
|
||||||
|
<path d="M2 21c0-3.5 3.5-6 7-6"/>
|
||||||
|
<path d="M17 7h4l-2 4h3l-5 6v-4h-2z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Emergency Contact Phone": (
|
||||||
|
<svg className="w-4 h-4 text-purple-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 4h4l2 5-3 2c1.2 2.4 3.1 4.3 5.5 5.5l2-3 5 2v4"/>
|
||||||
|
<path d="M18 2v4M18 10v.01"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// fallback for other fields
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankDetailsIcons = {
|
||||||
|
"Account Holder Name": (
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company Account Holder Name": (
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"IBAN": (
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"Company IBAN": (
|
||||||
|
<svg className="w-4 h-4 text-green-300" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// InfoCard with icons for each field
|
||||||
|
function InfoCard({ title, icon, data, color = "blue", badges = [] }) {
|
||||||
|
if (!data) return null;
|
||||||
|
const { t } = useTranslation('verify_user'); // added
|
||||||
|
const iconMap = color === "blue" ? userInfoIcons : color === "purple" ? companyInfoIcons : {};
|
||||||
|
|
||||||
|
// translation map
|
||||||
|
const labelTranslations = {
|
||||||
|
"First Name": t('fields.firstName'),
|
||||||
|
"Lastname": t('fields.lastName'),
|
||||||
|
"Full Name": t('fields.fullName'),
|
||||||
|
"Phone": t('fields.phone'),
|
||||||
|
"Email": t('fields.email'),
|
||||||
|
"Date Of Birth": t('fields.dateOfBirth'),
|
||||||
|
"Referrer Email": t('fields.referrerEmail'),
|
||||||
|
"Created At": t('fields.createdAt'),
|
||||||
|
"Updated At": t('fields.updatedAt'),
|
||||||
|
"Last Login At": t('fields.lastLoginAt'),
|
||||||
|
"Contact Person Name": t('fields.contactPersonName', { defaultValue: "Contact Person Name" }),
|
||||||
|
"Contact Person Phone": t('fields.contactPersonPhone', { defaultValue: "Contact Person Phone" }),
|
||||||
|
"Last Login": t('fields.lastLogin', { defaultValue: "Last Login" }),
|
||||||
|
"Nationality": t('fields.nationality'),
|
||||||
|
"Address": t('fields.address'),
|
||||||
|
"Zip Code": t('fields.zipCode'),
|
||||||
|
"City": t('fields.city'),
|
||||||
|
"Country": t('fields.country'),
|
||||||
|
"Phone Secondary": t('fields.phoneSecondary'),
|
||||||
|
"Emergency Contact Name": t('fields.emergencyContactName'),
|
||||||
|
"Emergency Contact Phone": t('fields.emergencyContactPhone'),
|
||||||
|
"Company Name": t('fields.companyName', { defaultValue: "Company Name" }),
|
||||||
|
"Company Email": t('fields.companyEmail', { defaultValue: "Company Email" }),
|
||||||
|
"Company Phone": t('fields.companyPhone', { defaultValue: "Company Phone" }),
|
||||||
|
"Company Address": t('fields.companyAddress', { defaultValue: "Company Address" }),
|
||||||
|
"Company Postal Code": t('fields.companyPostalCode', { defaultValue: "Company Postal Code" }),
|
||||||
|
"Company City": t('fields.companyCity', { defaultValue: "Company City" }),
|
||||||
|
"Company Country": t('fields.companyCountry', { defaultValue: "Company Country" }),
|
||||||
|
"Company Business Type": t('fields.companyBusinessType', { defaultValue: "Company Business Type" }),
|
||||||
|
"Company Number of Employees": t('fields.companyNumberOfEmployees', { defaultValue: "Company Number of Employees" }),
|
||||||
|
"Company Registration Number": t('fields.companyRegistrationNumber', { defaultValue: "Company Registration Number" })
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-${color}-50 border border-${color}-100 rounded-xl shadow p-6 flex flex-col gap-2`}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{icon}
|
||||||
|
<h2 className={`text-xl font-semibold text-${color}-900`}>{title}</h2>
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<div className="flex gap-2 ml-2">
|
||||||
|
{badges.map((badge, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={
|
||||||
|
badge.label === "Company"
|
||||||
|
? "px-2 py-1 rounded-full text-base font-semibold bg-purple-100 text-purple-800 border border-purple-200"
|
||||||
|
: badge.label === "Personal"
|
||||||
|
? "px-2 py-1 rounded-full text-base font-semibold bg-blue-100 text-blue-800 border border-blue-200"
|
||||||
|
: badge.className // fallback for other badges
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="text-gray-700 text-lg">
|
||||||
|
{Object.entries(data).map(([key, value]) => {
|
||||||
|
const label = labelTranslations[key] || formatLabel(key);
|
||||||
|
return (
|
||||||
|
<li key={key} className="mb-2 flex items-center gap-2">
|
||||||
|
{iconMap[key] || <span className="w-4 h-4"></span>}
|
||||||
|
<span className="font-medium">{label}:</span> {formatValue(value)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BankDetailsCard with icons
|
||||||
|
function BankDetailsCard({ accountHolderName, iban, isCompany }) {
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
const holderLabel = isCompany ? t('fields.companyAccountHolderName') : t('fields.accountHolderName');
|
||||||
|
const ibanLabel = isCompany ? t('fields.companyIban', { defaultValue: t('fields.iban') }) : t('fields.iban');
|
||||||
|
return (
|
||||||
|
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2 mt-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* Bank icon */}
|
||||||
|
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M3 10l9-7 9 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-semibold text-green-900">{t('headings.bankDetails')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-gray-700 text-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{bankDetailsIcons[isCompany ? "Company Account Holder Name" : "Account Holder Name"]}
|
||||||
|
<span className="font-medium">
|
||||||
|
{holderLabel}:
|
||||||
|
</span> {accountHolderName || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{bankDetailsIcons[isCompany ? "Company IBAN" : "IBAN"]}
|
||||||
|
<span className="font-medium">
|
||||||
|
{ibanLabel}:
|
||||||
|
</span> {iban || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserStatusStepper({ userStatus }) {
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
if (!userStatus || typeof userStatus !== "object") return null;
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
key: "email_verified",
|
||||||
|
label: t('steps.emailVerified'),
|
||||||
|
fulfilled: !!userStatus.email_verified,
|
||||||
|
date: userStatus.email_verified_at,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M16 12a4 4 0 01-8 0V8a4 4 0 018 0v4z" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "profile_completed",
|
||||||
|
label: t('steps.profileCompleted'),
|
||||||
|
fulfilled: !!userStatus.profile_completed,
|
||||||
|
date: userStatus.profile_completed_at,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M12 16v-4m0-4h.01" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "documents_uploaded",
|
||||||
|
label: t('steps.documentsUploaded'),
|
||||||
|
fulfilled: !!userStatus.documents_uploaded,
|
||||||
|
date: userStatus.documents_uploaded_at,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M8 11h8M8 15h6" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contract_signed",
|
||||||
|
label: t('steps.contractSigned'),
|
||||||
|
fulfilled: !!userStatus.contract_signed,
|
||||||
|
date: userStatus.contract_signed_at,
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Optionally, filter out steps that don't exist in userStatus
|
||||||
|
const filteredSteps = steps.filter(
|
||||||
|
step => step.key in userStatus || step.date
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-green-50 border border-green-100 rounded-xl shadow p-6 flex flex-col gap-2">
|
||||||
|
{/* User Status header and status badge in one row */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* Information Message icon */}
|
||||||
|
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<circle cx="12" cy="8" r="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-xl font-semibold text-green-900">{t('headings.userStatus')}</h2>
|
||||||
|
{userStatus.status && (
|
||||||
|
<span className={`ml-2 px-4 py-2 rounded-full font-semibold text-base border ${
|
||||||
|
userStatus.status === "active"
|
||||||
|
? "bg-green-100 text-green-800 border-green-200"
|
||||||
|
: userStatus.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||||
|
: "bg-gray-100 text-gray-800 border-gray-200"
|
||||||
|
}`}>
|
||||||
|
{formatLabel(userStatus.status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Four cards in one row, responsive */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-2">
|
||||||
|
{filteredSteps.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={step.key}
|
||||||
|
className={`flex flex-col items-center px-4 py-2 rounded-xl border shadow-sm min-w-[140px] ${
|
||||||
|
step.fulfilled
|
||||||
|
? "bg-green-100 border-green-200"
|
||||||
|
: "bg-gray-100 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{step.icon}
|
||||||
|
<span className={`font-semibold text-lg ${step.fulfilled ? "text-green-800" : "text-gray-500"}`}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{step.fulfilled ? (
|
||||||
|
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.date && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(step.date).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifyUserInformation({ userData }) {
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
if (!userData) return null;
|
||||||
|
const { user, profile, userStatus } = userData;
|
||||||
|
|
||||||
|
// Log backend data for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("VerifyUserInformation: user data", user);
|
||||||
|
console.log("VerifyUserInformation: profile data", profile);
|
||||||
|
console.log("VerifyUserInformation: userStatus data", userStatus);
|
||||||
|
}, [user, profile, userStatus]);
|
||||||
|
|
||||||
|
// Detect company user from both userType and user_type
|
||||||
|
const isCompany =
|
||||||
|
user?.userType === "company" ||
|
||||||
|
user?.user_type === "company";
|
||||||
|
|
||||||
|
// User Info fields for company
|
||||||
|
const companyUserInfo = {
|
||||||
|
"Contact Person Name": profile?.contact_person_name,
|
||||||
|
"Contact Person Phone": profile?.contact_person_phone,
|
||||||
|
"Created At": user?.createdAt ? new Date(user.createdAt).toLocaleString() : "-",
|
||||||
|
"Updated At": user?.updatedAt ? new Date(user.updatedAt).toLocaleString() : "-",
|
||||||
|
"Last Login": user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "-",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Company Info fields for company
|
||||||
|
const companyProfileInfo = {
|
||||||
|
"Company Name": profile?.company_name,
|
||||||
|
"Company Email": user?.email,
|
||||||
|
"Company Phone": profile?.phone,
|
||||||
|
"Company Address": profile?.address,
|
||||||
|
"Company Postal Code": profile?.zip_code,
|
||||||
|
"Company City": profile?.city,
|
||||||
|
"Company Country": profile?.country,
|
||||||
|
"Company Business Type": profile?.branch,
|
||||||
|
"Company Number of Employees": profile?.number_of_employees,
|
||||||
|
"Company Registration Number": profile?.registration_number,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- NEW: Ordered personal user info (only for personal users) ---
|
||||||
|
const personalUserInfo = {
|
||||||
|
"First Name": profile?.first_name ?? user?.firstName,
|
||||||
|
"Lastname": profile?.last_name ?? user?.lastName,
|
||||||
|
"Full Name": user?.fullName ?? `${profile?.first_name || ""} ${profile?.last_name || ""}`.trim(),
|
||||||
|
"Phone": user?.phone ?? profile?.phone,
|
||||||
|
"Email": user?.email,
|
||||||
|
"Date Of Birth": user?.dateOfBirth || profile?.date_of_birth,
|
||||||
|
"Referrer Email": user?.referralEmail,
|
||||||
|
"Created At": user?.createdAt,
|
||||||
|
"Updated At": user?.updatedAt,
|
||||||
|
"Last Login At": user?.lastLoginAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const personalProfileInfo = {
|
||||||
|
"Nationality": profile?.nationality,
|
||||||
|
"Address": profile?.address,
|
||||||
|
"Zip Code": profile?.zip_code,
|
||||||
|
"City": profile?.city,
|
||||||
|
"Country": profile?.country,
|
||||||
|
"Phone Secondary": profile?.phone_secondary,
|
||||||
|
"Emergency Contact Name": profile?.emergency_contact_name,
|
||||||
|
"Emergency Contact Phone": profile?.emergency_contact_phone
|
||||||
|
};
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// User type badge
|
||||||
|
const userTypeBadge = isCompany
|
||||||
|
? { label: "Company", className: "bg-purple-100 text-purple-800 border border-purple-200" }
|
||||||
|
: { label: "Personal", className: "bg-blue-100 text-blue-800 border border-blue-200" };
|
||||||
|
|
||||||
|
// Role badge
|
||||||
|
const roleBadge = user?.role
|
||||||
|
? { label: user.role, className: "bg-yellow-100 text-yellow-800 border border-yellow-200" }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
const statusBadge = userStatus?.status
|
||||||
|
? {
|
||||||
|
label: userStatus.status,
|
||||||
|
className:
|
||||||
|
userStatus.status === "active"
|
||||||
|
? "bg-green-100 text-green-800 border border-green-200"
|
||||||
|
: userStatus.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-800 border border-yellow-200"
|
||||||
|
: "bg-gray-100 text-gray-800 border border-gray-200"
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Card titles based on user type
|
||||||
|
const profileTitle = isCompany ? t('headings.companyInfo') : t('headings.profileInfo');
|
||||||
|
|
||||||
|
// Bank details for company and personal users
|
||||||
|
const bankDetails = isCompany
|
||||||
|
? {
|
||||||
|
accountHolderName: profile?.account_holder_name,
|
||||||
|
iban: user?.iban || profile?.iban,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
accountHolderName: profile?.account_holder_name || user?.accountHolderName,
|
||||||
|
iban: user?.iban || profile?.iban,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 w-full">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||||
|
<InfoCard
|
||||||
|
title={t('headings.userInfo')}
|
||||||
|
icon={
|
||||||
|
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
data={isCompany ? companyUserInfo : personalUserInfo}
|
||||||
|
color="blue"
|
||||||
|
badges={[userTypeBadge, roleBadge].filter(Boolean)}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title={profileTitle}
|
||||||
|
icon={
|
||||||
|
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
data={isCompany ? companyProfileInfo : personalProfileInfo}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Bank Details Card below User/Profile Info */}
|
||||||
|
<BankDetailsCard
|
||||||
|
accountHolderName={bankDetails.accountHolderName}
|
||||||
|
iban={bankDetails.iban}
|
||||||
|
isCompany={isCompany}
|
||||||
|
/>
|
||||||
|
{/* Modern User Status Stepper below */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<UserStatusStepper userStatus={userStatus} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserInformation;
|
||||||
317
src/features/admin/verifyUser/components/VerifyUserList.jsx
Normal file
317
src/features/admin/verifyUser/components/VerifyUserList.jsx
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useVerificationPendingUsers } from "../hooks/useVerifyUser";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function VerifyUserList({ pageSize = 10, filters = {} }) {
|
||||||
|
const { users: allUsers, loading, error } = useVerificationPendingUsers();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
|
||||||
|
// Reset to page 1 when filters or pageSize change
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [filters, pageSize]);
|
||||||
|
|
||||||
|
// Client-side filtering
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
let users = allUsers;
|
||||||
|
if (filters.search) {
|
||||||
|
const search = filters.search.toLowerCase();
|
||||||
|
users = users.filter(
|
||||||
|
(u) =>
|
||||||
|
(u.email && u.email.toLowerCase().includes(search)) ||
|
||||||
|
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
|
||||||
|
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
|
||||||
|
(u.company_name && u.company_name.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.user_type) {
|
||||||
|
users = users.filter((u) => u.user_type === filters.user_type);
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
users = users.filter((u) => u.status === filters.status);
|
||||||
|
}
|
||||||
|
if (filters.role) {
|
||||||
|
users = users.filter((u) => u.role === filters.role);
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}, [allUsers, filters]);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalUsers = filteredUsers.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize));
|
||||||
|
const pagedUsers = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return filteredUsers.slice(start, start + pageSize);
|
||||||
|
}, [filteredUsers, page, pageSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('headings.queueTitle')}</h2>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{loading
|
||||||
|
? t('headings.loadingUser')
|
||||||
|
: t('table.showing', { shown: pagedUsers.length, total: totalUsers })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.user')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.type')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.created')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.lastLogin')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||||
|
{t('table.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center text-gray-400">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : error ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center text-red-500">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : pagedUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-12 text-center">
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 mx-auto mb-4 text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500 text-lg font-medium">
|
||||||
|
{t('table.noUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t('table.adjustFilters')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pagedUsers.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="hover:bg-gray-50 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{user.user_type === "personal"
|
||||||
|
? `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`
|
||||||
|
: user.company_name?.substring(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900">
|
||||||
|
{user.user_type === "personal"
|
||||||
|
? `${user.first_name ?? ""} ${user.last_name ?? ""}`
|
||||||
|
: user.company_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.user_type === "company"
|
||||||
|
? "bg-purple-100 text-purple-800 border border-purple-200"
|
||||||
|
: "bg-blue-100 text-blue-800 border border-blue-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full mr-2 ${
|
||||||
|
user.user_type === "company"
|
||||||
|
? "bg-purple-400"
|
||||||
|
: "bg-blue-400"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
{user.user_type === "company" ? "Company" : "Personal"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{user.status === "active" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
|
||||||
|
<div className="w-2 h-2 bg-green-400 rounded-full mr-2"></div>
|
||||||
|
{t('status.active')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user.status === "pending" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||||
|
<div className="w-2 h-2 bg-yellow-400 rounded-full mr-2"></div>
|
||||||
|
{t('status.pending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user.status === "suspended" && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200">
|
||||||
|
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
|
||||||
|
{t('status.suspended')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.role === "super_admin"
|
||||||
|
? "bg-red-100 text-red-800 border border-red-200"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-indigo-100 text-indigo-800 border border-indigo-200"
|
||||||
|
: "bg-gray-100 text-gray-800 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full mr-2 ${
|
||||||
|
user.role === "super_admin"
|
||||||
|
? "bg-red-400"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-indigo-400"
|
||||||
|
: "bg-gray-400"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
{t(`roles.${user.role}`)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-900 font-medium">
|
||||||
|
{user.created_at?.slice(0, 10) ?? "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-900">
|
||||||
|
{user.last_login_at ? (
|
||||||
|
<span className="font-medium">
|
||||||
|
{user.last_login_at.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">{t('table.never')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded-md text-green-700 bg-green-50 hover:bg-green-100 hover:border-green-400 transition-colors duration-200"
|
||||||
|
onClick={() => navigate(`/admin/user-management/verify/${user.id}`)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.verify')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('table.pageOf', { page, pages: totalPages })}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500"> ({t('table.totalUsers', { total: totalUsers })})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
page === 1
|
||||||
|
? "text-gray-400 bg-white cursor-not-allowed"
|
||||||
|
: "text-gray-700 bg-white hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.previous')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
page === totalPages
|
||||||
|
? "text-gray-400 bg-white cursor-not-allowed"
|
||||||
|
: "text-gray-700 bg-white hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
{t('buttons.next')}
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 ml-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserList;
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function VerifyUserListFilterForm({ filters = {}, setFilters, pageSize, setPageSize }) {
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFilters({ ...filters, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFilters({ ...filters });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white overflow-hidden shadow-lg rounded-xl border border-gray-100 mb-8">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">{t('headings.filterTitle')}</h2>
|
||||||
|
<p className="text-gray-600 mt-1">{t('headings.filterSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
name="search"
|
||||||
|
value={filters.search || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
type="text"
|
||||||
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.userType')}</label>
|
||||||
|
<select
|
||||||
|
name="user_type"
|
||||||
|
value={filters.user_type || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allTypes')}</option>
|
||||||
|
<option value="personal">{t('filters.personal')}</option>
|
||||||
|
<option value="company">{t('filters.company')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.role')}</label>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={filters.role || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allRoles')}</option>
|
||||||
|
<option value="user">{t('roles.user')}</option>
|
||||||
|
<option value="admin">{t('roles.admin')}</option>
|
||||||
|
<option value="super_admin">{t('roles.super_admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.usersPerPage')}</label>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={e => setPageSize(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-black"
|
||||||
|
>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white px-4 py-2 rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{t('buttons.filter')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserListFilterForm;
|
||||||
@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { authFetch } from "../../../../utils/authFetch";
|
||||||
|
import { showToast } from "../../../toast/toastUtils.js";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function VerifyUserSetPermission({ userData }) {
|
||||||
|
const [allPermissions, setAllPermissions] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(
|
||||||
|
Array.isArray(userData?.permissions)
|
||||||
|
? userData.permissions.map((p) => (typeof p === "string" ? p : p.name))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [successMsg, setSuccessMsg] = useState("");
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
|
||||||
|
// Fetch all available permissions
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPermissions() {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/permissions`,
|
||||||
|
{ method: "GET" }
|
||||||
|
);
|
||||||
|
console.log("[VerifyUserSetPermission] Permissions fetch response:", res);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch permissions");
|
||||||
|
const data = await res.json();
|
||||||
|
console.log("[VerifyUserSetPermission] Permissions response data:", data);
|
||||||
|
// If permissions are objects, map to names
|
||||||
|
setAllPermissions(
|
||||||
|
Array.isArray(data.permissions)
|
||||||
|
? data.permissions.map((perm) =>
|
||||||
|
typeof perm === "string" ? perm : perm.name
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(t('messages.loadPermFail'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPermissions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Log selected permissions whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[VerifyUserSetPermission] Selected permissions:", selected);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
// Handle checkbox change
|
||||||
|
const handleCheckbox = (perm) => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(perm)
|
||||||
|
? prev.filter((p) => p !== perm)
|
||||||
|
: [...prev, perm]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle verify & set permissions
|
||||||
|
const handleVerify = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setSuccessMsg("");
|
||||||
|
setErrorMsg("");
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/admin/verify-user/${
|
||||||
|
userData.user?.id || userData.user?.user_id || userData.user_id
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ permissions: selected }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.message || "Verification failed");
|
||||||
|
}
|
||||||
|
setSuccessMsg(t('messages.verifySuccess'));
|
||||||
|
showToast({ type: "success", message: t('messages.verifySuccess') });
|
||||||
|
// Redirect after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/admin/verify-users");
|
||||||
|
}, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(err.message || t('messages.verifyFail'));
|
||||||
|
showToast({ type: "error", message: err.message || t('messages.verifyFail') });
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="bg-white rounded-2xl shadow-xl border border-blue-100 max-w-2xl mx-auto p-8 mt-12"
|
||||||
|
onSubmit={handleVerify}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-blue-900 mb-6 text-center">
|
||||||
|
{t('headings.setPermissionsTitle')}
|
||||||
|
</h2>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-gray-700 font-medium mb-4 text-center">
|
||||||
|
{t('buttons.verify')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center">
|
||||||
|
{allPermissions.length === 0 ? (
|
||||||
|
<span className="text-gray-400">{t('messages.noPermissions')}</span>
|
||||||
|
) : (
|
||||||
|
allPermissions.map((perm) => (
|
||||||
|
<label
|
||||||
|
key={perm}
|
||||||
|
className="flex items-center bg-blue-50 px-4 py-2 rounded-lg shadow border border-blue-200 cursor-pointer hover:border-blue-400 transition"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mr-2 accent-blue-600"
|
||||||
|
checked={selected.includes(perm)}
|
||||||
|
onChange={() => handleCheckbox(perm)}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-blue-900 font-semibold">{perm}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full mt-4 px-6 py-3 bg-blue-700 text-white font-bold rounded-lg shadow hover:bg-blue-800 transition disabled:opacity-60"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? t('messages.verifying') : t('buttons.verifySet')}
|
||||||
|
</button>
|
||||||
|
{successMsg && (
|
||||||
|
<div className="mt-4 text-green-600 font-semibold text-center">{successMsg}</div>
|
||||||
|
)}
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="mt-4 text-red-600 font-semibold text-center">{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserSetPermission;
|
||||||
95
src/features/admin/verifyUser/hooks/useVerifyUser.js
Normal file
95
src/features/admin/verifyUser/hooks/useVerifyUser.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import useAuthStore from "../../../../store/authStore";
|
||||||
|
import {
|
||||||
|
fetchVerificationPendingUsers,
|
||||||
|
fetchVerifyUserFull,
|
||||||
|
fetchVerifyUserDocuments,
|
||||||
|
} from "../api/verifyUserApi";
|
||||||
|
import { log } from "../../../../utils/logger";
|
||||||
|
|
||||||
|
export function useVerificationPendingUsers() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useVerificationPendingUsers: loading users");
|
||||||
|
fetchVerificationPendingUsers(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
log("useVerificationPendingUsers: fetched users", data);
|
||||||
|
setUsers(data.users || []);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useVerificationPendingUsers: error loading users", err);
|
||||||
|
setError("Failed to load users: " + err.message);
|
||||||
|
setUsers([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { users, loading, error, reload: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifyUserFull(id) {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [userData, setUserData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useVerifyUserFull: fetching user full data for id", id);
|
||||||
|
fetchVerifyUserFull(accessToken, id)
|
||||||
|
.then((data) => {
|
||||||
|
log("useVerifyUserFull: fetched user data", data);
|
||||||
|
setUserData(data);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useVerifyUserFull: error fetching user data", err);
|
||||||
|
setError("Failed to fetch user data: " + err.message);
|
||||||
|
setUserData(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken, id]);
|
||||||
|
|
||||||
|
return { userData, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifyUserDocuments(id) {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [docsData, setDocsData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
log("useVerifyUserDocuments: fetching user documents for id", id);
|
||||||
|
fetchVerifyUserDocuments(accessToken, id)
|
||||||
|
.then((data) => {
|
||||||
|
log("useVerifyUserDocuments: fetched docs data", data);
|
||||||
|
setDocsData(data);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("useVerifyUserDocuments: error fetching docs", err);
|
||||||
|
setError("Failed to fetch user documents: " + err.message);
|
||||||
|
setDocsData(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken, id]);
|
||||||
|
|
||||||
|
return { docsData, loading, error };
|
||||||
|
}
|
||||||
91
src/features/admin/verifyUser/pages/VerifyUser.jsx
Normal file
91
src/features/admin/verifyUser/pages/VerifyUser.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useVerifyUserFull, useVerifyUserDocuments } from "../hooks/useVerifyUser";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import VerifyUserInformation from "../components/VerifyUserInformation";
|
||||||
|
import VerifyUserDocuments from "../components/VerifyUserDocuments";
|
||||||
|
import VerifyUserSetPermission from "../components/VerifyUserSetPermission";
|
||||||
|
|
||||||
|
function VerifyUser() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
const { userData, loading: loadingUser, error: errorUser } = useVerifyUserFull(id);
|
||||||
|
const { docsData, loading: loadingDocs, error: errorDocs } = useVerifyUserDocuments(id);
|
||||||
|
|
||||||
|
const loading = loadingUser || loadingDocs;
|
||||||
|
const err = errorUser || errorDocs;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-blue-600 font-bold text-xl">{t('headings.loadingUser')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err || !userData) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg px-8 py-12 text-center">
|
||||||
|
<span className="text-red-600 font-bold text-xl">{err || t('headings.userNotFound')}</span>
|
||||||
|
<button
|
||||||
|
className="mt-6 px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
{t('buttons.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader showFooter>
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<main className="relative z-10 flex justify-center px-1 sm:px-8 w-full">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl shadow-lg p-3 sm:p-8 w-full bg-white"
|
||||||
|
style={{
|
||||||
|
maxWidth: "1600px",
|
||||||
|
marginTop: "0.5%",
|
||||||
|
marginBottom: "2%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="mb-6 px-4 py-2 bg-blue-50 text-blue-700 rounded hover:bg-blue-100"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
{t('buttons.back')}
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold text-blue-900 mb-6 text-center">
|
||||||
|
{t('headings.verifyUser')}
|
||||||
|
</h1>
|
||||||
|
{/* User Info/Profile/Status */}
|
||||||
|
<VerifyUserInformation userData={userData} />
|
||||||
|
{/* Media/Documents */}
|
||||||
|
<VerifyUserDocuments docsData={docsData} />
|
||||||
|
{/* Keep VerifyUserSetPermission as is */}
|
||||||
|
<div className="my-8">
|
||||||
|
<VerifyUserSetPermission userData={userData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUser;
|
||||||
53
src/features/admin/verifyUser/pages/VerifyUserQueue.jsx
Normal file
53
src/features/admin/verifyUser/pages/VerifyUserQueue.jsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import VerifyUserListFilterForm from "../components/VerifyUserListFilterForm";
|
||||||
|
import VerifyUserList from "../components/VerifyUserList";
|
||||||
|
import GlobalAnimatedBackground from "../../../../background/GlobalAnimatedBackground";
|
||||||
|
import PageLayout from "../../../PageLayout";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function VerifyUserQueue() {
|
||||||
|
const [filters, setFilters] = useState({});
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const { t } = useTranslation('verify_user');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={true}>
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
{/* Remove all extra vertical padding/margin from parent containers */}
|
||||||
|
<main className="flex-1 flex justify-center items-start px-1 sm:px-8 w-full">
|
||||||
|
<div
|
||||||
|
className="rounded-lg shadow-lg p-3 sm:p-8 bg-gray-100 w-full"
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5%", // Match dashboard/referral management top margin
|
||||||
|
marginBottom: "2%",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "1600px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl sm:text-4xl font-extrabold text-blue-900 mb-2 text-center tracking-tight">
|
||||||
|
{t('headings.queueTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-base text-blue-900 text-center font-semibold mb-4 sm:mb-8">
|
||||||
|
{t('headings.queueSubtitle')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg w-full">
|
||||||
|
<VerifyUserListFilterForm
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
/>
|
||||||
|
<VerifyUserList
|
||||||
|
filters={filters}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyUserQueue;
|
||||||
47
src/features/dashboard/api/dashboardApi.js
Normal file
47
src/features/dashboard/api/dashboardApi.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { authFetch } from "../../../utils/authFetch";
|
||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export async function fetchUserStatusApi() {
|
||||||
|
log("fetchUserStatusApi called");
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/user/status`,
|
||||||
|
{ method: "GET", credentials: "include" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchUserStatusApi failed with status:", res.status);
|
||||||
|
throw new Error("Failed to fetch user status");
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchUserStatusApi success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserApi() {
|
||||||
|
log("fetchUserApi called");
|
||||||
|
const res = await authFetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/me`,
|
||||||
|
{ method: "GET", credentials: "include" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("fetchUserApi failed with status:", res.status);
|
||||||
|
throw new Error("Failed to fetch user");
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("fetchUserApi success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshTokenApi() {
|
||||||
|
log("refreshTokenApi called");
|
||||||
|
const res = await fetch(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/api/auth/refresh`,
|
||||||
|
{ method: "POST", credentials: "include" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
log("refreshTokenApi failed with status:", res.status);
|
||||||
|
throw new Error("Failed to refresh token");
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
log("refreshTokenApi success, response:", json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
84
src/features/dashboard/components/InformationSection.jsx
Normal file
84
src/features/dashboard/components/InformationSection.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function InformationSection() {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
{/* Latest News */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg shadow p-6 mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-blue-900 mb-3">
|
||||||
|
{t("latestNews.title")}
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc pl-5 text-blue-800 text-lg space-y-3">
|
||||||
|
<li>
|
||||||
|
☕ <strong>{t("latestNews.new")}</strong>{" "}
|
||||||
|
{t("latestNews.coffeeMachinePrefix")}
|
||||||
|
<a
|
||||||
|
href="https://www.profit-planet.shop/collections/vitapresso/products/vitapresso-coffee-capsel-abo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-700 underline hover:text-blue-900 text-lg"
|
||||||
|
>
|
||||||
|
{t("latestNews.coffeeMachineLink")}
|
||||||
|
</a>{" "}
|
||||||
|
{t("latestNews.coffeeMachineSuffix")}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
🚀 <strong>{t("latestNews.referralTitle")}</strong>{" "}
|
||||||
|
{t("latestNews.referralDesc")}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
🛒 {t("latestNews.webshopDesc")}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/* Enhanced Webshop Card */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-2xl shadow-lg p-8 flex flex-col sm:flex-row items-center justify-between mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-bold text-blue-900 mb-3">
|
||||||
|
{t("webshop.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-blue-800 text-lg mb-4">
|
||||||
|
{t("webshop.desc")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://www.profit-planet.shop/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block px-6 py-3 bg-blue-600 text-white text-lg font-semibold rounded-lg shadow hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
{t("webshop.button")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center justify-center ml-8">
|
||||||
|
<svg
|
||||||
|
className="w-20 h-20 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="18"
|
||||||
|
width="28"
|
||||||
|
height="16"
|
||||||
|
rx="6"
|
||||||
|
fill="#dbeafe"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M14 18V14a4 4 0 014-4h12a4 4 0 014 4v4"
|
||||||
|
/>
|
||||||
|
<circle cx="18" cy="34" r="3" fill="#3b82f6" />
|
||||||
|
<circle cx="30" cy="34" r="3" fill="#3b82f6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/features/dashboard/components/RegisterAdminMessage.jsx
Normal file
77
src/features/dashboard/components/RegisterAdminMessage.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function RegisterAdminMessage({ userStatus, user }) {
|
||||||
|
const { t } = useTranslation(["dashboard"]);
|
||||||
|
|
||||||
|
if (!userStatus || !user) {
|
||||||
|
console.log("⚠️ RegisterAdminMessage: Missing userStatus or user data");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎯 RegisterAdminMessage: Checking display conditions with status:", userStatus);
|
||||||
|
|
||||||
|
// Map the real API status to our expected format
|
||||||
|
const status = {
|
||||||
|
emailVerified: userStatus.email_verified === 1,
|
||||||
|
idDocumentProvided: userStatus.documents_uploaded === 1,
|
||||||
|
additionalInfoComplete: userStatus.profile_completed === 1,
|
||||||
|
contractSigned: userStatus.contract_signed === 1,
|
||||||
|
isAdminVerified: userStatus.is_admin_verified === 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📊 RegisterAdminMessage: Mapped status:", status);
|
||||||
|
|
||||||
|
// Only show if all steps are completed but admin verification is still pending
|
||||||
|
const allStepsCompleted =
|
||||||
|
status.emailVerified &&
|
||||||
|
status.idDocumentProvided &&
|
||||||
|
status.additionalInfoComplete &&
|
||||||
|
status.contractSigned;
|
||||||
|
const adminVerificationPending = !status.isAdminVerified;
|
||||||
|
|
||||||
|
const shouldShow = allStepsCompleted && adminVerificationPending;
|
||||||
|
|
||||||
|
console.log("🔍 RegisterAdminMessage: Display conditions:", {
|
||||||
|
allStepsCompleted,
|
||||||
|
adminVerificationPending,
|
||||||
|
shouldShow,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldShow) {
|
||||||
|
console.log("❌ RegisterAdminMessage: Conditions not met, hiding component");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ RegisterAdminMessage: Showing admin approval message");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg shadow-sm p-6 mb-8 w-full">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-yellow-400 mr-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xl font-bold text-yellow-800 mb-2">
|
||||||
|
{t("admin.pendingApprovalTitle")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-yellow-700 text-lg">
|
||||||
|
{t("admin.pendingApprovalDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterAdminMessage;
|
||||||
191
src/features/dashboard/components/RegisterQuickAction.jsx
Normal file
191
src/features/dashboard/components/RegisterQuickAction.jsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
function RegisterQuickAction({ userStatus, user }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation(['dashboard','common']);
|
||||||
|
const [showContractInfo, setShowContractInfo] = useState(false);
|
||||||
|
|
||||||
|
if (!userStatus || !user) {
|
||||||
|
log("⚠️ RegisterQuickAction: Missing userStatus or user data");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("🎯 RegisterQuickAction: Rendering with status:", userStatus);
|
||||||
|
|
||||||
|
// Map the real API status to our expected format
|
||||||
|
const status = {
|
||||||
|
emailVerified: userStatus.email_verified === 1,
|
||||||
|
idDocumentProvided: userStatus.documents_uploaded === 1,
|
||||||
|
additionalInfoComplete: userStatus.profile_completed === 1,
|
||||||
|
contractSigned: userStatus.contract_signed === 1,
|
||||||
|
isAdminVerified: userStatus.is_admin_verified === 1
|
||||||
|
};
|
||||||
|
|
||||||
|
log("📊 RegisterQuickAction: Mapped status:", status);
|
||||||
|
|
||||||
|
// If all steps are completed, don't show quick actions
|
||||||
|
const allStepsCompleted = status.emailVerified && status.idDocumentProvided &&
|
||||||
|
status.additionalInfoComplete && status.contractSigned;
|
||||||
|
|
||||||
|
if (allStepsCompleted) {
|
||||||
|
log("✅ RegisterQuickAction: All steps completed, hiding component");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the correct route based on user type
|
||||||
|
const getRoute = (actionType) => {
|
||||||
|
const userType = user.userType;
|
||||||
|
const routes = {
|
||||||
|
personal: {
|
||||||
|
'verify-email': '/personal-verify-email',
|
||||||
|
'upload-id': '/personal-id-upload',
|
||||||
|
'complete-profile': '/personal-complete-profile',
|
||||||
|
'sign-contract': '/personal-sign-contract'
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
'verify-email': '/company-verify-email',
|
||||||
|
'upload-id': '/company-id-upload',
|
||||||
|
'complete-profile': '/company-complete-profile',
|
||||||
|
'sign-contract': '/company-sign-contract'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return routes[userType]?.[actionType] || '/dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to handle navigation
|
||||||
|
const handleNavigation = (actionType, label) => {
|
||||||
|
const route = getRoute(actionType);
|
||||||
|
log(`🧭 RegisterQuickAction: Navigating to ${route} for action: ${label}`);
|
||||||
|
navigate(route);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the available actions based on completion status
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
// Email verification - show if not verified
|
||||||
|
if (!status.emailVerified) {
|
||||||
|
actions.push({
|
||||||
|
key: "verify-email",
|
||||||
|
label: t('dashboard:actions.verifyEmail'),
|
||||||
|
color: "#dc2626", // red
|
||||||
|
hoverColor: "#b91c1c",
|
||||||
|
onClick: () => handleNavigation("verify-email", t('dashboard:actions.verifyEmail')),
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID Document upload - show if not uploaded
|
||||||
|
if (!status.idDocumentProvided) {
|
||||||
|
actions.push({
|
||||||
|
key: "upload-id",
|
||||||
|
label: user.userType === "personal" ? t('dashboard:actions.uploadIdDocument') : t('dashboard:actions.uploadContactId'),
|
||||||
|
color: "#2563eb", // blue
|
||||||
|
hoverColor: "#1d4ed8",
|
||||||
|
onClick: () => handleNavigation("upload-id", t('dashboard:actions.uploadIdDocument')),
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile completion - show if not completed
|
||||||
|
if (!status.additionalInfoComplete) {
|
||||||
|
actions.push({
|
||||||
|
key: "complete-profile",
|
||||||
|
label: t('dashboard:actions.completeProfile'),
|
||||||
|
color: "#2563eb", // blue
|
||||||
|
hoverColor: "#1d4ed8",
|
||||||
|
onClick: () => handleNavigation("complete-profile", t('dashboard:actions.completeProfile')),
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract signing - show if not signed
|
||||||
|
let contractActionDisabled = false;
|
||||||
|
if (!status.contractSigned) {
|
||||||
|
const canSignContract = status.emailVerified && status.idDocumentProvided && status.additionalInfoComplete;
|
||||||
|
contractActionDisabled = !canSignContract;
|
||||||
|
actions.push({
|
||||||
|
key: "sign-contract",
|
||||||
|
label: t('dashboard:actions.signContract'),
|
||||||
|
color: canSignContract ? "#7c3aed" : "#a3a3a3", // purple or gray
|
||||||
|
hoverColor: canSignContract ? "#6d28d9" : "#a3a3a3",
|
||||||
|
onClick: canSignContract
|
||||||
|
? () => handleNavigation("sign-contract", t('dashboard:actions.signContract'))
|
||||||
|
: () => setShowContractInfo(true),
|
||||||
|
disabled: contractActionDisabled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log("🎮 RegisterQuickAction: Available actions:", actions.map(a => a.label));
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
log("⚠️ RegisterQuickAction: No actions available, hiding component");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-blue-100 rounded-xl shadow-lg p-8 mb-8 w-full">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 mr-4">
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 text-blue-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-700 mt-1 text-xl font-bold">
|
||||||
|
{t('headings.quickActions')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Responsive button layout */}
|
||||||
|
<div className={`flex flex-col gap-3 w-full sm:flex-row sm:gap-4 ${actions.length === 1 ? 'justify-center' : ''}`}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<div key={action.key} className="w-full sm:flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full font-semibold py-4 text-lg rounded-lg shadow transition focus:outline-none ${action.disabled ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||||
|
style={{ background: action.color, color: "#fff" }}
|
||||||
|
onMouseOver={e => !action.disabled && (e.currentTarget.style.background = action.hoverColor)}
|
||||||
|
onMouseOut={e => e.currentTarget.style.background = action.color}
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
{/* Feedback message for contract button if disabled */}
|
||||||
|
{action.key === "sign-contract" && action.disabled && (
|
||||||
|
<div className="mt-2 text-center text-red-600 font-medium text-sm">
|
||||||
|
{t('dashboard:actions.signContractBlocked', {
|
||||||
|
defaultValue: "You need to complete the previous steps (email verification, ID upload, profile completion) before signing the contract."
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Feedback message for contract button */}
|
||||||
|
{showContractInfo && (
|
||||||
|
<div className="mt-4 text-center text-red-600 font-medium">
|
||||||
|
{t('dashboard:actions.signContractBlocked', {
|
||||||
|
defaultValue: "You need to complete the previous steps (email verification, ID upload, profile completion) before signing the contract."
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterQuickAction;
|
||||||
195
src/features/dashboard/components/StatusSection.jsx
Normal file
195
src/features/dashboard/components/StatusSection.jsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Modern, Google-like status card
|
||||||
|
function StatusCard({ icon, label, value, bgColor, textColor }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl shadow-md flex flex-col items-center justify-center py-7 px-4 transition hover:shadow-lg`} style={{ backgroundColor: bgColor }}>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-full mb-3" style={{ backgroundColor: bgColor }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-base font-medium mb-1" style={{ color: textColor }}>{label}</div>
|
||||||
|
<div className="text-lg font-bold" style={{ color: textColor }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact card for mobile
|
||||||
|
function CompactStatusCard({ icon, label, value, bgColor, textColor }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg flex items-center px-3 py-2 mb-2 shadow-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
minHeight: "44px",
|
||||||
|
fontSize: "0.95rem"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-full mr-3" style={{ backgroundColor: bgColor }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium" style={{ color: textColor }}>{label}</div>
|
||||||
|
<div className="font-bold" style={{ color: textColor }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusSection({ status, user }) {
|
||||||
|
const { t } = useTranslation(['dashboard','common']);
|
||||||
|
|
||||||
|
if (!status || !user) {
|
||||||
|
console.log("⚠️ StatusSection: Missing status or user data");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing is uploaded, all are missing
|
||||||
|
const nothingUploaded =
|
||||||
|
status.email_verified !== 1 &&
|
||||||
|
status.documents_uploaded !== 1 &&
|
||||||
|
status.profile_completed !== 1 &&
|
||||||
|
status.contract_signed !== 1;
|
||||||
|
|
||||||
|
// Map the real API status to our expected format
|
||||||
|
const mappedStatus = {
|
||||||
|
emailVerified: nothingUploaded ? false : status.email_verified === 1,
|
||||||
|
idDocumentProvided: nothingUploaded ? false : status.documents_uploaded === 1,
|
||||||
|
additionalInfoComplete: nothingUploaded ? false : status.profile_completed === 1,
|
||||||
|
contractSigned: nothingUploaded ? false : status.contract_signed === 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Card data for each status
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: t('dashboard:labels.emailVerification'),
|
||||||
|
value: mappedStatus.emailVerified ? t('common:status.verified') : t('common:status.missing'),
|
||||||
|
bgColor: mappedStatus.emailVerified ? "#E8F5E9" : "#FFEBEE", // green or red
|
||||||
|
textColor: mappedStatus.emailVerified ? "#34A853" : "#EA4335",
|
||||||
|
icon: (
|
||||||
|
mappedStatus.emailVerified ? (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: user.userType === "personal" ? t('dashboard:labels.idDocument') : t('dashboard:labels.contactId'),
|
||||||
|
value: mappedStatus.idDocumentProvided ? t('common:status.uploaded') : t('common:status.missing'),
|
||||||
|
bgColor: mappedStatus.idDocumentProvided ? "#E8F5E9" : "#FFEBEE",
|
||||||
|
textColor: mappedStatus.idDocumentProvided ? "#34A853" : "#EA4335",
|
||||||
|
icon: (
|
||||||
|
mappedStatus.idDocumentProvided ? (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('dashboard:labels.additionalInfo'),
|
||||||
|
value: mappedStatus.additionalInfoComplete ? t('common:status.complete') : t('common:status.missing'),
|
||||||
|
bgColor: mappedStatus.additionalInfoComplete ? "#E8F5E9" : "#FFEBEE",
|
||||||
|
textColor: mappedStatus.additionalInfoComplete ? "#34A853" : "#EA4335",
|
||||||
|
icon: (
|
||||||
|
mappedStatus.additionalInfoComplete ? (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('dashboard:labels.contract'),
|
||||||
|
value: mappedStatus.contractSigned ? t('common:status.signed') : t('common:status.missing'),
|
||||||
|
bgColor: mappedStatus.contractSigned ? "#E8F5E9" : "#FFEBEE",
|
||||||
|
textColor: mappedStatus.contractSigned ? "#34A853" : "#EA4335",
|
||||||
|
icon: (
|
||||||
|
mappedStatus.contractSigned ? (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#34A853" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-7 h-7" fill="none" stroke="#EA4335" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="#EA4335" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" stroke="#EA4335" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Responsive grid columns and padding
|
||||||
|
const isMobile = window.innerWidth < 640;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white border border-blue-100 rounded-xl shadow-lg w-full`}
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? '0.5rem' : '2rem',
|
||||||
|
marginBottom: isMobile ? '0.5rem' : '2rem',
|
||||||
|
boxShadow: isMobile ? 'none' : undefined,
|
||||||
|
borderRadius: isMobile ? '0.5rem' : '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-xl font-bold text-blue-900 mb-4"
|
||||||
|
style={{
|
||||||
|
marginBottom: isMobile ? '0.5rem' : '1.5rem',
|
||||||
|
fontSize: isMobile ? '1.1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('dashboard:headings.statusOverview')}
|
||||||
|
</h3>
|
||||||
|
<div className="w-full">
|
||||||
|
{isMobile ? (
|
||||||
|
<div>
|
||||||
|
{cards.map((card, idx) => (
|
||||||
|
<CompactStatusCard
|
||||||
|
key={idx}
|
||||||
|
icon={card.icon}
|
||||||
|
label={<span>{card.label}</span>}
|
||||||
|
value={<span>{card.value}</span>}
|
||||||
|
bgColor={card.bgColor}
|
||||||
|
textColor={card.textColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-6 w-full">
|
||||||
|
{cards.map((card, idx) => (
|
||||||
|
<StatusCard
|
||||||
|
key={idx}
|
||||||
|
icon={card.icon}
|
||||||
|
label={<span className="text-lg">{card.label}</span>}
|
||||||
|
value={<span className="text-lg font-bold">{card.value}</span>}
|
||||||
|
bgColor={card.bgColor}
|
||||||
|
textColor={card.textColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusSection;
|
||||||
129
src/features/dashboard/hooks/useDashboard.js
Normal file
129
src/features/dashboard/hooks/useDashboard.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import useAuthStore from "../../../store/authStore";
|
||||||
|
import { fetchUserStatusApi, fetchUserApi, refreshTokenApi } from "../api/dashboardApi";
|
||||||
|
import { authFetch } from "../../../utils/authFetch";
|
||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const { accessToken, user, setAccessToken, setUser, clearAuth } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [userStatus, setUserStatus] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch user status from API
|
||||||
|
const fetchUserStatus = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
log("useDashboard: fetchUserStatus called for user:", user?.email || user?.id);
|
||||||
|
try {
|
||||||
|
const statusData = await fetchUserStatusApi();
|
||||||
|
log("useDashboard: fetched user status:", statusData);
|
||||||
|
setUserStatus(statusData.status || statusData);
|
||||||
|
} catch (err) {
|
||||||
|
log("useDashboard: error fetching user status:", err);
|
||||||
|
setUserStatus({
|
||||||
|
emailVerified: false,
|
||||||
|
idDocumentProvided: false,
|
||||||
|
additionalInfoComplete: false,
|
||||||
|
contractSigned: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial authentication and user fetch
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkAuth() {
|
||||||
|
log("useDashboard: checkAuth called. accessToken:", accessToken, "user:", user);
|
||||||
|
if (!accessToken) {
|
||||||
|
try {
|
||||||
|
const data = await refreshTokenApi();
|
||||||
|
log("useDashboard: refreshed token and user:", data);
|
||||||
|
setAccessToken(data.accessToken);
|
||||||
|
setUser(data.user);
|
||||||
|
} catch (err) {
|
||||||
|
log("useDashboard: failed to refresh token, clearing auth.", err);
|
||||||
|
clearAuth();
|
||||||
|
navigate("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken && !user) {
|
||||||
|
try {
|
||||||
|
const userData = await fetchUserApi();
|
||||||
|
log("useDashboard: fetched user data:", userData);
|
||||||
|
setUser(userData.user || userData);
|
||||||
|
} catch (err) {
|
||||||
|
log("useDashboard: error fetching user data:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch user status when user is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !userStatus) {
|
||||||
|
log("useDashboard: user available, fetching status.");
|
||||||
|
fetchUserStatus();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (!user) return "Loading...";
|
||||||
|
if (user.userType === "personal") {
|
||||||
|
return `${user.firstName || ""} ${user.lastName || ""}`.trim() || "User";
|
||||||
|
}
|
||||||
|
return user.companyName || user.name || user.email || "User";
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowAdminMessage = () => {
|
||||||
|
if (!userStatus) return false;
|
||||||
|
const status = {
|
||||||
|
emailVerified: userStatus.email_verified === 1,
|
||||||
|
idDocumentProvided: userStatus.documents_uploaded === 1,
|
||||||
|
additionalInfoComplete: userStatus.profile_completed === 1,
|
||||||
|
contractSigned: userStatus.contract_signed === 1,
|
||||||
|
isAdminVerified: userStatus.is_admin_verified === 1,
|
||||||
|
};
|
||||||
|
const allStepsCompleted =
|
||||||
|
status.emailVerified &&
|
||||||
|
status.idDocumentProvided &&
|
||||||
|
status.additionalInfoComplete &&
|
||||||
|
status.contractSigned;
|
||||||
|
const adminVerificationPending = !status.isAdminVerified;
|
||||||
|
return allStepsCompleted && adminVerificationPending;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowQuickActions = () => {
|
||||||
|
if (!userStatus || !user) return false;
|
||||||
|
const status = {
|
||||||
|
emailVerified: userStatus.email_verified === 1,
|
||||||
|
idDocumentProvided: userStatus.documents_uploaded === 1,
|
||||||
|
additionalInfoComplete: userStatus.profile_completed === 1,
|
||||||
|
contractSigned: userStatus.contract_signed === 1,
|
||||||
|
};
|
||||||
|
const allStepsCompleted =
|
||||||
|
status.emailVerified &&
|
||||||
|
status.idDocumentProvided &&
|
||||||
|
status.additionalInfoComplete &&
|
||||||
|
status.contractSigned;
|
||||||
|
return !allStepsCompleted;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
user,
|
||||||
|
userStatus,
|
||||||
|
isLoading,
|
||||||
|
getDisplayName,
|
||||||
|
shouldShowAdminMessage,
|
||||||
|
shouldShowQuickActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
151
src/features/dashboard/pages/Dashboard.jsx
Normal file
151
src/features/dashboard/pages/Dashboard.jsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PageLayout from "../../PageLayout";
|
||||||
|
import StatusSection from "../components/StatusSection";
|
||||||
|
import RegisterQuickAction from "../components/RegisterQuickAction";
|
||||||
|
import RegisterAdminMessage from "../components/RegisterAdminMessage";
|
||||||
|
import GlobalAnimatedBackground from "../../../background/GlobalAnimatedBackground";
|
||||||
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import InformationSection from "../components/InformationSection";
|
||||||
|
import Footer from "../../nav/Footer"; // Import Footer
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const {
|
||||||
|
accessToken,
|
||||||
|
user,
|
||||||
|
userStatus,
|
||||||
|
isLoading,
|
||||||
|
getDisplayName,
|
||||||
|
shouldShowAdminMessage,
|
||||||
|
shouldShowQuickActions,
|
||||||
|
} = useDashboard();
|
||||||
|
const { t } = useTranslation(['dashboard','common']);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={true}>
|
||||||
|
<div className="min-h-screen w-full flex items-center justify-center">
|
||||||
|
<div className="text-lg">{t('common:status.loading')}</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={window.innerWidth >= 640}>
|
||||||
|
<div className="relative min-h-screen w-full flex flex-col overflow-hidden">
|
||||||
|
<GlobalAnimatedBackground variant="dashboard" />
|
||||||
|
<div className="relative z-10 w-full flex justify-center">
|
||||||
|
{/* Mobile: remove bg-gray-100 and inner wrapper, move Footer inside */}
|
||||||
|
{window.innerWidth < 640 ? (
|
||||||
|
<div className="w-full" style={{ margin: 0, padding: 0 }}>
|
||||||
|
<div
|
||||||
|
className="rounded-none shadow-none p-0 bg-white w-full"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
maxWidth: "100vw",
|
||||||
|
width: "100vw",
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "stretch",
|
||||||
|
paddingBottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-3xl font-extrabold text-blue-900 mb-3 text-center tracking-tight"
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('headings.welcome', { name: getDisplayName() })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-blue-900 text-center font-semibold mb-6">
|
||||||
|
{user.userType === "personal" ? t('accountType.personal') : t('accountType.company')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-none p-0 shadow-none">
|
||||||
|
<div className="mb-6" />
|
||||||
|
{/* Status Overview */}
|
||||||
|
{userStatus && (
|
||||||
|
<div className="mb-6 w-full">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 shadow-md w-full">
|
||||||
|
<StatusSection status={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Quick Actions */}
|
||||||
|
{shouldShowQuickActions() && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 shadow-md w-full mb-8">
|
||||||
|
<RegisterQuickAction userStatus={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Admin Message */}
|
||||||
|
{shouldShowAdminMessage() && (
|
||||||
|
<div className="mt-6 bg-yellow-50 rounded-lg p-3 shadow-md w-full mb-6">
|
||||||
|
<RegisterAdminMessage userStatus={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Information Section */}
|
||||||
|
<InformationSection />
|
||||||
|
</div>
|
||||||
|
{/* Move Footer here for mobile */}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex justify-center py-2 px-1 sm:px-4 w-full"
|
||||||
|
style={{
|
||||||
|
padding: undefined,
|
||||||
|
margin: '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg shadow-lg p-4 sm:p-8 bg-gray-100 w-full"
|
||||||
|
style={{
|
||||||
|
margin: '0',
|
||||||
|
borderRadius: undefined,
|
||||||
|
boxShadow: undefined,
|
||||||
|
maxWidth: "1600px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl sm:text-6xl font-extrabold text-blue-900 mb-3 text-center tracking-tight">
|
||||||
|
{t('headings.welcome', { name: getDisplayName() })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg sm:text-xl text-blue-900 text-center font-semibold mb-6 sm:mb-10">
|
||||||
|
{user.userType === "personal" ? t('accountType.personal') : t('accountType.company')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-2 sm:p-4 shadow-lg">
|
||||||
|
<div className="mb-6 sm:mb-10" />
|
||||||
|
{userStatus && (
|
||||||
|
<div className="mb-6 sm:mb-10 w-full">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 sm:p-6 shadow-md w-full">
|
||||||
|
<StatusSection status={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shouldShowQuickActions() && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 sm:p-6 shadow-md w-full mb-8">
|
||||||
|
<RegisterQuickAction userStatus={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shouldShowAdminMessage() && (
|
||||||
|
<div className="mt-6 sm:mt-10 bg-yellow-50 rounded-lg p-3 sm:p-6 shadow-md w-full mb-8">
|
||||||
|
<RegisterAdminMessage userStatus={userStatus} user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InformationSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/features/error/pages/404.jsx
Normal file
32
src/features/error/pages/404.jsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Footer from "../../nav/Footer";
|
||||||
|
import GlobalAnimatedBackground from "../../../background/GlobalAnimatedBackground";
|
||||||
|
|
||||||
|
const NotFoundPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col min-h-screen overflow-hidden">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
<div className="flex-grow flex flex-col items-center justify-center z-10 relative">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl px-10 py-12 max-w-md w-full text-center">
|
||||||
|
<h1 className="text-7xl font-extrabold text-blue-600 mb-4">404</h1>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 mb-2">Page Not Found</h2>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
Sorry, the page you are looking for does not exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
Go back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
92
src/features/login/api/loginApi.js
Normal file
92
src/features/login/api/loginApi.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { getCurrentLanguage } from "../../../i18n/i18n";
|
||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export async function loginApi(email, password) {
|
||||||
|
// Extended diagnostics
|
||||||
|
const viteEnv = (import.meta && import.meta.env) ? import.meta.env : {};
|
||||||
|
const viteVars = Object.fromEntries(
|
||||||
|
Object.entries(viteEnv).filter(([k]) => k.startsWith('VITE_'))
|
||||||
|
);
|
||||||
|
log('loginApi VITE_* keys detected:', Object.keys(viteVars));
|
||||||
|
log('loginApi raw import.meta.env.VITE_API_BASE_URL:', viteEnv.VITE_API_BASE_URL);
|
||||||
|
|
||||||
|
// Multi-source fallback resolution
|
||||||
|
const candidates = [
|
||||||
|
[viteEnv.VITE_API_BASE_URL, 'import.meta.env'],
|
||||||
|
[globalThis.VITE_API_BASE_URL, 'globalThis.VITE_API_BASE_URL'],
|
||||||
|
[globalThis.__ENV__?.VITE_API_BASE_URL, 'globalThis.__ENV__.VITE_API_BASE_URL'],
|
||||||
|
[typeof document !== 'undefined' ? document.querySelector('meta[name="vite-api-base-url"]')?.getAttribute('content') : undefined, 'meta tag'],
|
||||||
|
[typeof process !== 'undefined' ? process.env?.VITE_API_BASE_URL : undefined, 'process.env (build-time only in Vite)']
|
||||||
|
];
|
||||||
|
let resolved = { value: undefined, source: null };
|
||||||
|
for (const [val, src] of candidates) {
|
||||||
|
if (val) { resolved = { value: val, source: src }; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBase = resolved.value;
|
||||||
|
log(`loginApi resolved base:`, rawBase, 'source:', resolved.source);
|
||||||
|
|
||||||
|
if (!rawBase) {
|
||||||
|
log('[loginApi] VITE_API_BASE_URL unresolved. Ensure one of:');
|
||||||
|
log(' - .env(.local) contains VITE_API_BASE_URL=...');
|
||||||
|
log(' - Rebuilt after setting it (PM2 uses built assets, variables are NOT injected at runtime).');
|
||||||
|
log(' - If runtime injection desired, expose window.VITE_API_BASE_URL or meta[name="vite-api-base-url"].');
|
||||||
|
throw new Error('Missing VITE_API_BASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = rawBase.replace(/\/+$/, '');
|
||||||
|
log('loginApi normalized base:', normalizedBase);
|
||||||
|
const endpoint = /\/api$/i.test(normalizedBase)
|
||||||
|
? normalizedBase + '/auth/login'
|
||||||
|
: normalizedBase + '/api/auth/login';
|
||||||
|
log('loginApi endpoint:', endpoint);
|
||||||
|
|
||||||
|
function maskEmail(e) {
|
||||||
|
if (!e) return '';
|
||||||
|
const [u, d] = e.split('@');
|
||||||
|
if (!d || !u) return '***';
|
||||||
|
return u[0] + '***@' + d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const lang = getCurrentLanguage();
|
||||||
|
log(`[loginApi] starting POST ${endpoint} email=${maskEmail(email)} t=${start.toFixed(2)}ms`);
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ email, password, lang }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
const elapsed = end - start;
|
||||||
|
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||||
|
log(`[loginApi] response meta status=${res.status} ok=${res.ok} content-type=${contentType} elapsed=${elapsed.toFixed(2)}ms`);
|
||||||
|
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const snippet = (await res.clone().text()).slice(0, 200);
|
||||||
|
log(`[loginApi] Non-JSON content-type (${contentType || 'none'}). First 200 chars: ${JSON.stringify(snippet)}. Possible wrong VITE_API_BASE_URL or endpoint.`);
|
||||||
|
} catch (e) {
|
||||||
|
log('[loginApi] Failed to read non-JSON body snippet:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
log('[loginApi] JSON parse starting');
|
||||||
|
data = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
log('[loginApi] JSON parse error:', err);
|
||||||
|
throw new Error('Non-JSON response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log('[loginApi] API error payload:', data);
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[loginApi] success keys:', Array.isArray(data) ? 'array(length=' + data.length + ')' : Object.keys(data).join(','));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
277
src/features/login/components/LoginForm.jsx
Normal file
277
src/features/login/components/LoginForm.jsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { useLogin } from '../hooks/useLogin';
|
||||||
|
import { loginSchema } from '../../../validation/login/loginSchema';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showBall, setShowBall] = useState(true);
|
||||||
|
|
||||||
|
// React Hook Form setup
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm({
|
||||||
|
resolver: yupResolver(loginSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { login, error: loginError, setError: setLoginError, loading } = useLogin();
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setShowBall(window.innerWidth >= 768);
|
||||||
|
};
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
setLoginError("");
|
||||||
|
await login(data.email, data.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex justify-center items-center min-h-screen py-8 relative"
|
||||||
|
style={{
|
||||||
|
minHeight: 'calc(100vh - 100px)',
|
||||||
|
paddingTop: window.innerWidth < 768 ? '0.25rem' : '5rem', // Even less top padding for mobile
|
||||||
|
paddingBottom: window.innerWidth < 768 ? '2.5rem' : '2.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl flex flex-col items-center relative border-t-4 border-blue-600"
|
||||||
|
style={{
|
||||||
|
width: window.innerWidth < 768 ? '98vw' : '40vw', // Wider on mobile
|
||||||
|
maxWidth: window.innerWidth < 768 ? 'none' : '700px',
|
||||||
|
minWidth: window.innerWidth < 768 ? '0' : '400px',
|
||||||
|
minHeight: window.innerWidth < 768 ? '320px' : '320px',
|
||||||
|
padding: window.innerWidth < 768 ? '0.5rem' : '2rem',
|
||||||
|
marginTop: window.innerWidth < 768 ? '0.5rem' : undefined,
|
||||||
|
transform: window.innerWidth < 768 ? undefined : 'scale(0.85)',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ball: mittig, überlappend, nur auf Desktop sichtbar using showBall state */}
|
||||||
|
{showBall && ( // Use showBall state here
|
||||||
|
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-28 z-20">
|
||||||
|
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-200 to-blue-400 flex items-center justify-center shadow-xl border-4 border-white relative">
|
||||||
|
<svg className="w-20 h-20 text-blue-400" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="20" fill="currentColor" />
|
||||||
|
<ellipse cx="32" cy="38" rx="16" ry="5" fill="#2563eb" fillOpacity=".10" />
|
||||||
|
<ellipse cx="32" cy="26" rx="10" ry="4" fill="#2563eb" fillOpacity=".08" />
|
||||||
|
<circle cx="40" cy="26" r="3" fill="#2563eb" fillOpacity=".5" />
|
||||||
|
</svg>
|
||||||
|
{/* Orbiting balls */}
|
||||||
|
<span className="absolute left-1/2 top-1/2 w-0 h-0">
|
||||||
|
<span className="block absolute animate-orbit-true" style={{ width: 0, height: 0 }}>
|
||||||
|
<span className="block w-3 h-3 bg-blue-400 rounded-full shadow-lg" style={{ transform: 'translateX(44px)' }}></span>
|
||||||
|
</span>
|
||||||
|
<span className="block absolute animate-orbit-true2" style={{ width: 0, height: 0 }}>
|
||||||
|
<span className="block w-2.5 h-2.5 bg-teal-400 rounded-full shadow-md" style={{ transform: 'translateX(-36px)' }}></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Move content up inside the form */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: window.innerWidth < 768 ? '0.5rem' : '1.5rem', // Move Profit Planet title up
|
||||||
|
marginBottom: window.innerWidth < 768 ? '1.5rem' : '2rem', // More space at bottom
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
<h1
|
||||||
|
className="mb-2 text-center text-4xl font-extrabold text-blue-900 tracking-tight drop-shadow-lg"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '2rem' : undefined,
|
||||||
|
marginTop: window.innerWidth < 768 ? '0.5rem' : undefined, // Move title up
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profit Planet
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mb-8 text-center text-lg text-blue-500 font-medium"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.95rem' : undefined,
|
||||||
|
marginBottom: window.innerWidth < 768 ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.tagline')}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="space-y-7 w-full"
|
||||||
|
style={{
|
||||||
|
// Reduced gap for mobile
|
||||||
|
gap: window.innerWidth < 768 ? '0.75rem' : undefined,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-base font-semibold text-blue-900 mb-1"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for label
|
||||||
|
marginBottom: window.innerWidth < 768 ? '0.25rem' : undefined, // Reduced margin
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.emailLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="appearance-none block w-full px-4 py-3 border border-blue-200 rounded-lg placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 text-base bg-white text-blue-900 transition"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for input
|
||||||
|
padding: window.innerWidth < 768 ? '0.4rem 0.75rem' : undefined, // Smaller padding for input
|
||||||
|
}}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-base font-semibold text-blue-900 mb-1"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for label
|
||||||
|
marginBottom: window.innerWidth < 768 ? '0.25rem' : undefined, // Reduced margin
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.passwordLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="appearance-none block w-full px-4 py-3 border border-blue-200 rounded-lg placeholder-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 text-base bg-white text-blue-900 transition"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for input
|
||||||
|
padding: window.innerWidth < 768 ? '0.4rem 0.75rem' : undefined, // Smaller padding for input
|
||||||
|
}}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center">
|
||||||
|
<input
|
||||||
|
id="show-password"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 border-black border-2 rounded focus:ring-blue-500 bg-transparent" // Transparent background with black border
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="show-password" className="ml-2 text-sm text-gray-700">{t('login.showPassword')}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loginError && (
|
||||||
|
<div
|
||||||
|
className="text-red-500 text-base"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Attempt translation; fallback to original message */}
|
||||||
|
{t(loginError, { defaultValue: loginError })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200 transform hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.9rem' : undefined, // Slightly smaller font size for button
|
||||||
|
padding: window.innerWidth < 768 ? '0.6rem 1rem' : undefined, // Smaller padding for button
|
||||||
|
}}
|
||||||
|
disabled={loading} // Disable button while loading
|
||||||
|
>
|
||||||
|
{loading ? t('login.signingIn') : t('login.signIn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-600 hover:underline text-sm font-medium"
|
||||||
|
onClick={() => navigate("/password-reset")}
|
||||||
|
>
|
||||||
|
{t('login.forgotPassword', { defaultValue: "Forgot password?" })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
className="mt-10 w-full"
|
||||||
|
style={{
|
||||||
|
marginTop: window.innerWidth < 768 ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-blue-100"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative flex justify-center text-base"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.875rem' : undefined, // Smaller font size for "Need an account?"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="px-3 bg-white text-blue-400">{t('login.needAccount')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-7 text-center"
|
||||||
|
style={{
|
||||||
|
marginTop: window.innerWidth < 768 ? '0.75rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-base text-blue-500"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.8rem' : undefined, // Smaller font size for description
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.inviteOnlyDescription')}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-base text-blue-400 mt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: window.innerWidth < 768 ? '0.8rem' : undefined, // Smaller font size for contact info
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.contactSupport')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Animations for orbiting balls */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes orbit-true {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes orbit-true2 {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
.animate-orbit-true { animation: orbit-true 3s linear infinite; transform-origin: 0 0; }
|
||||||
|
.animate-orbit-true2 { animation: orbit-true2 4s linear infinite; transform-origin: 0 0; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
39
src/features/login/hooks/useLogin.js
Normal file
39
src/features/login/hooks/useLogin.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import useAuthStore from "../../../store/authStore";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { showToast } from "../../toast/toastUtils";
|
||||||
|
import { loginApi } from "../api/loginApi";
|
||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const setAccessToken = useAuthStore((s) => s.setAccessToken);
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
log("useLogin: login called", { email });
|
||||||
|
try {
|
||||||
|
const data = await loginApi(email, password);
|
||||||
|
log("useLogin: login success", { user: data.user });
|
||||||
|
setAccessToken(data.accessToken);
|
||||||
|
setUser(data.user);
|
||||||
|
sessionStorage.setItem("userType", data.user.userType);
|
||||||
|
sessionStorage.setItem("role", data.user.role || "user");
|
||||||
|
showToast({ type: "success", message: "Login successful!" });
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
log("useLogin: login error", err);
|
||||||
|
setError(err.message || "An error occurred. Please try again.");
|
||||||
|
showToast({ type: "error", message: err.message || "An error occurred. Please try again." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { login, error, setError, loading };
|
||||||
|
}
|
||||||
36
src/features/login/pages/Login.jsx
Normal file
36
src/features/login/pages/Login.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import LoginForm from '../components/LoginForm'
|
||||||
|
import PageLayout from '../../PageLayout'
|
||||||
|
import GlobalAnimatedBackground from '../../../background/GlobalAnimatedBackground' // Re-import the animation component
|
||||||
|
import Footer from '../../nav/Footer'
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const [showBackground, setShowBackground] = useState(() => window.innerWidth >= 768);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setShowBackground(window.innerWidth >= 768);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showHeader={true} showFooter={false}>
|
||||||
|
<div className="relative w-screen flex flex-col">
|
||||||
|
{/* Animated background behind everything */}
|
||||||
|
{showBackground && (
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 flex-1 flex items-center justify-center py-12 px-4 overflow-hidden">
|
||||||
|
<div className="w-full">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
11
src/features/nav/Footer.jsx
Normal file
11
src/features/nav/Footer.jsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="w-full bg-white border-t border-gray-200 py-6 mt-12 text-center text-sm text-gray-500 z-10 relative">
|
||||||
|
© {new Date().getFullYear()} Profit Planet. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
478
src/features/nav/Header.jsx
Normal file
478
src/features/nav/Header.jsx
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import useAuthStore from "../../store/authStore";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { showToast } from "../toast/toastUtils";
|
||||||
|
import LanguageSwitcher from "./LanguageSwitcher"; // added
|
||||||
|
import { log } from "../../utils/logger";
|
||||||
|
|
||||||
|
// Modern, sleek navbar: left-aligned logo/title, right-aligned nav/profile
|
||||||
|
function Header() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
const mobileMenuRef = useRef(null);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout); // use logout from store
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Close mobile menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (
|
||||||
|
mobileMenuRef.current &&
|
||||||
|
!mobileMenuRef.current.contains(e.target) &&
|
||||||
|
!e.target.closest(".mobile-hamburger")
|
||||||
|
) {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
log("🚪 Header: User logout initiated");
|
||||||
|
try {
|
||||||
|
log("🌐 Header: Calling Zustand logout (will call /api/auth/logout)");
|
||||||
|
await logout();
|
||||||
|
log("✅ Header: Zustand logout completed");
|
||||||
|
showToast({ type: "success", tKey: "toast:logoutSuccess" });
|
||||||
|
log("🧭 Header: Redirecting to login");
|
||||||
|
navigate("/login");
|
||||||
|
} catch (err) {
|
||||||
|
log("❌ Header: Error during logout:", err);
|
||||||
|
showToast({ type: "error", message: "Logout failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check permission
|
||||||
|
const canAccessReferrals =
|
||||||
|
user &&
|
||||||
|
(
|
||||||
|
user.role === "admin" ||
|
||||||
|
user.role === "super_admin" ||
|
||||||
|
(Array.isArray(user.permissions) && user.permissions.includes("can_create_referrals"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to get profile/settings route
|
||||||
|
const getProfileRoute = () =>
|
||||||
|
user?.userType === "company" ? "/company/profile" : "/personal/profile";
|
||||||
|
const getSettingsRoute = () =>
|
||||||
|
user?.userType === "company" ? "/company/settings" : "/personal/settings";
|
||||||
|
|
||||||
|
const handleProfileClick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(getProfileRoute());
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsClick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(getSettingsRoute());
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get user initials for profile icon
|
||||||
|
const getUserInitials = () => {
|
||||||
|
if (!user) return "U";
|
||||||
|
if (user.userType === "company" && user.companyName) {
|
||||||
|
return user.companyName
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return (
|
||||||
|
(user.firstName?.[0] || "") +
|
||||||
|
(user.lastName?.[0] || "")
|
||||||
|
).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.name) {
|
||||||
|
return user.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.email) {
|
||||||
|
return user.email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
return "U";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check if user is admin
|
||||||
|
const isAdmin = sessionStorage.getItem("role") === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="w-full bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<div className="w-full max-w-[1600px] flex items-center justify-between px-0 py-3 md:px-6">
|
||||||
|
{/* Left: SVG Logo */}
|
||||||
|
<div className="flex items-center gap-3 pl-4 md:pl-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-32 h-14 flex items-center md:w-40 focus:outline-none"
|
||||||
|
onClick={() => navigate("/dashboard")}
|
||||||
|
aria-label="Go to dashboard"
|
||||||
|
style={{ background: "none", border: "none", padding: 0 }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/img/profitplanet_logo.svg"
|
||||||
|
alt="Profit Planet Logo"
|
||||||
|
className="h-9 w-auto md:h-10 transition-transform duration-300 ease-in-out hover:scale-110"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* If not logged in, only show language switcher */}
|
||||||
|
{!user && (
|
||||||
|
<div className="flex items-center pr-4 md:pr-6 ml-auto">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden">
|
||||||
|
<LanguageSwitcher compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Desktop Navigation + Profile (only when authenticated) */}
|
||||||
|
{user && (
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
{canAccessReferrals && (
|
||||||
|
<a
|
||||||
|
href="/referral-management"
|
||||||
|
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Referrals
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{/* Admin dropdown */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="relative group">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded-lg text-blue-900 font-medium hover:bg-blue-100 hover:text-blue-700 transition flex items-center gap-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 9l-7 7-7-7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="absolute left-0 mt-2 bg-white rounded-xl shadow-xl border border-blue-100 z-50 min-w-[200px] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-opacity">
|
||||||
|
{/* Add Admin Dashboard as first entry */}
|
||||||
|
<a
|
||||||
|
href="/admin/dashboard"
|
||||||
|
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Admin Dashboard
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/user-management"
|
||||||
|
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
User Management
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/permission-management"
|
||||||
|
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Permission Management
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/verify-users"
|
||||||
|
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Verify Users
|
||||||
|
</a>
|
||||||
|
{/* Add Contract Dashboard link */}
|
||||||
|
<a
|
||||||
|
href="/admin/contract-dashboard"
|
||||||
|
className="block px-6 py-3 text-blue-900 hover:bg-blue-50 hover:text-blue-700 transition"
|
||||||
|
>
|
||||||
|
Contract Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<LanguageSwitcher /> {/* restored for logged-in desktop users */}
|
||||||
|
{/* Profile Icon + Dropdown */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className="avatar avatar-placeholder focus:outline-none"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-label="Open profile menu"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-gradient-to-br from-blue-100 via-blue-200 to-blue-300 text-blue-900 shadow-lg border-2 border-blue-200">
|
||||||
|
<span className="text-base font-semibold">{getUserInitials()}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-2 bg-white rounded-xl shadow-xl border border-blue-100 z-50"
|
||||||
|
style={{
|
||||||
|
minWidth: "14rem",
|
||||||
|
width: "max-content",
|
||||||
|
maxWidth: "90vw"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<div className="font-bold text-blue-900 text-lg break-words">
|
||||||
|
{user?.name || user?.companyName || "Test User"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 break-words">
|
||||||
|
{user?.email || "test@test.com"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col py-2">
|
||||||
|
<a
|
||||||
|
href={getProfileRoute()}
|
||||||
|
className="px-6 py-2 text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition cursor-pointer"
|
||||||
|
onClick={handleProfileClick}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-6 py-2 text-red-600 font-semibold hover:bg-red-50 hover:text-red-700 text-left transition cursor-pointer"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Mobile Hamburger + Language Switcher (when authenticated) */}
|
||||||
|
{user && (
|
||||||
|
<div className="md:hidden flex items-center pr-4 gap-2">
|
||||||
|
{/* Language switcher next to hamburger */}
|
||||||
|
<LanguageSwitcher compact />
|
||||||
|
<button
|
||||||
|
className="mobile-hamburger flex flex-col justify-center items-center w-10 h-10 rounded focus:outline-none"
|
||||||
|
aria-label="Open menu"
|
||||||
|
onClick={() => setMobileMenuOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className={`block w-7 h-0.5 bg-blue-900 mb-1 transition-all duration-300 ${mobileMenuOpen ? "rotate-45 translate-y-2" : ""}`}></span>
|
||||||
|
<span className={`block w-7 h-0.5 bg-blue-900 mb-1 transition-all duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}></span>
|
||||||
|
<span className={`block w-7 h-0.5 bg-blue-900 transition-all duration-300 ${mobileMenuOpen ? "-rotate-45 -translate-y-2" : ""}`}></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile Slide Menu */}
|
||||||
|
{user && (
|
||||||
|
<div
|
||||||
|
ref={mobileMenuRef}
|
||||||
|
className={`fixed top-0 left-0 h-full w-full sm:w-80 bg-white/80 backdrop-blur-md shadow-2xl z-[999]
|
||||||
|
transition-transform duration-300 md:hidden
|
||||||
|
${mobileMenuOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
|
${mobileMenuOpen ? "animate-slide-in" : "animate-slide-out"}
|
||||||
|
`}
|
||||||
|
style={{ willChange: "transform" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between px-2 py-3 border-b border-gray-100">
|
||||||
|
<span className="flex items-center ml-1">
|
||||||
|
<img
|
||||||
|
src="/img/profitplanet_logo.svg"
|
||||||
|
alt="Profit Planet Logo"
|
||||||
|
className="h-5 w-auto"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-blue-900 text-xl focus:outline-none mr-1"
|
||||||
|
aria-label="Close menu"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Remove language switcher from inside mobile menu */}
|
||||||
|
<nav className="flex flex-col gap-0 px-6 py-4">
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
{canAccessReferrals && (
|
||||||
|
<a
|
||||||
|
href="/referral-management"
|
||||||
|
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Referrals
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{/* Admin mobile dropdown */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-2 w-full text-left text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30 flex items-center justify-between"
|
||||||
|
onClick={() => setAdminDropdownOpen((v) => !v)}
|
||||||
|
aria-expanded={adminDropdownOpen}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
Admin
|
||||||
|
<svg className="w-4 h-4 ml-1 inline" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 9l-7 7-7-7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Show dropdown if open, with slide animation */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
overflow-hidden
|
||||||
|
transition-all duration-300
|
||||||
|
${adminDropdownOpen ? "max-h-96 animate-admin-slide-in" : "max-h-0 animate-admin-slide-out"}
|
||||||
|
`}
|
||||||
|
style={{ willChange: "max-height" }}
|
||||||
|
>
|
||||||
|
{adminDropdownOpen && (
|
||||||
|
<div className="ml-4 flex flex-col bg-white rounded shadow border border-blue-100 mt-1 mb-2">
|
||||||
|
<a
|
||||||
|
href="/admin/dashboard"
|
||||||
|
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
|
||||||
|
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
|
||||||
|
>
|
||||||
|
Admin Dashboard
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/user-management"
|
||||||
|
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
|
||||||
|
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
|
||||||
|
>
|
||||||
|
User Management
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/permission-management"
|
||||||
|
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
|
||||||
|
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
|
||||||
|
>
|
||||||
|
Permission Management
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/verify-users"
|
||||||
|
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
|
||||||
|
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
|
||||||
|
>
|
||||||
|
Verify Users
|
||||||
|
</a>
|
||||||
|
{/* Add Contract Dashboard link */}
|
||||||
|
<a
|
||||||
|
href="/admin/contract-dashboard"
|
||||||
|
className="py-2 px-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition"
|
||||||
|
onClick={() => { setMobileMenuOpen(false); setAdminDropdownOpen(false); }}
|
||||||
|
>
|
||||||
|
Contract Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={getProfileRoute()}
|
||||||
|
className="py-2 text-blue-900 font-medium hover:bg-blue-100 rounded transition border-b border-blue-200/30"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(getProfileRoute());
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-2 text-red-600 font-semibold hover:bg-red-50 hover:text-red-700 rounded text-left transition border-b border-blue-200/30"
|
||||||
|
onClick={async () => {
|
||||||
|
await handleLogout();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="mt-auto px-6 py-4 text-xs text-blue-300">
|
||||||
|
© {new Date().getFullYear()} Profit Planet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Overlay for mobile menu */}
|
||||||
|
{user && mobileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[998] md:hidden pointer-events-none"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
{/* Visual separation below header */}
|
||||||
|
<div className="w-full h-2 bg-gradient-to-b from-gray-100 to-transparent"></div>
|
||||||
|
{/* Animation styles */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideInMenu {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes slideOutMenu {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slideInMenu 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
|
||||||
|
}
|
||||||
|
.animate-slide-out {
|
||||||
|
animation: slideOutMenu 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes adminSlideIn {
|
||||||
|
from { max-height: 0; opacity: 0; }
|
||||||
|
to { max-height: 500px; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes adminSlideOut {
|
||||||
|
from { max-height: 500px; opacity: 1; }
|
||||||
|
to { max-height: 0; opacity: 0; }
|
||||||
|
}
|
||||||
|
.animate-admin-slide-in {
|
||||||
|
animation: adminSlideIn 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
|
||||||
|
}
|
||||||
|
.animate-admin-slide-out {
|
||||||
|
animation: adminSlideOut 0.3s cubic-bezier(0.4,0,0.2,1) forwards;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
46
src/features/nav/LanguageSwitcher.jsx
Normal file
46
src/features/nav/LanguageSwitcher.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getCurrentLanguage, setLanguage } from '../../i18n/i18n';
|
||||||
|
|
||||||
|
// Props: compact (bool) for mobile usage
|
||||||
|
export default function LanguageSwitcher({ compact = false }) {
|
||||||
|
const { t, i18n } = useTranslation('common');
|
||||||
|
const current = i18n.language || getCurrentLanguage();
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setLanguage(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClass =
|
||||||
|
'border rounded-lg bg-white text-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-300 transition';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={compact ? 'mt-2 flex items-center gap-2' : 'flex items-center gap-2'}
|
||||||
|
style={{ minWidth: compact ? 'auto' : '140px' }}
|
||||||
|
>
|
||||||
|
{!compact && (
|
||||||
|
<label
|
||||||
|
htmlFor={`lang-switcher-${compact ? 'c' : 'd'}`}
|
||||||
|
className="text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
{t('language.switcher.label')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={`lang-switcher-${compact ? 'c' : 'd'}`}
|
||||||
|
value={current}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={
|
||||||
|
baseClass +
|
||||||
|
' text-sm px-2 py-1 ' +
|
||||||
|
(compact ? 'h-8' : 'h-9')
|
||||||
|
}
|
||||||
|
aria-label={t('language.switcher.label')}
|
||||||
|
>
|
||||||
|
<option value="en">{t('language.switcher.english')}</option>
|
||||||
|
<option value="de">{t('language.switcher.german')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/features/password-reset/api/usePasswordResetApi.js
Normal file
41
src/features/password-reset/api/usePasswordResetApi.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export async function requestPasswordResetApi(email) {
|
||||||
|
log("requestPasswordResetApi called", { email });
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/request-password-reset`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await res.json();
|
||||||
|
log("requestPasswordResetApi error response:", errorData);
|
||||||
|
} catch (e) {
|
||||||
|
errorData = {};
|
||||||
|
log("requestPasswordResetApi error parsing response:", e);
|
||||||
|
}
|
||||||
|
const error = new Error(errorData.error || "Request failed");
|
||||||
|
error.status = res.status;
|
||||||
|
error.data = errorData;
|
||||||
|
log("requestPasswordResetApi failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log("requestPasswordResetApi success");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetTokenApi(token) {
|
||||||
|
log("verifyPasswordResetTokenApi called", { token });
|
||||||
|
return fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/verify-password-reset?token=${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPasswordApi(token, newPassword) {
|
||||||
|
log("resetPasswordApi called", { token, newPassword: !!newPassword });
|
||||||
|
return fetch(`${import.meta.env.VITE_API_BASE_URL}/api/auth/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, newPassword })
|
||||||
|
});
|
||||||
|
}
|
||||||
70
src/features/password-reset/components/PasswordResetForm.jsx
Normal file
70
src/features/password-reset/components/PasswordResetForm.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { showToast } from "../../toast/toastUtils";
|
||||||
|
import { requestPasswordResetApi } from "../api/usePasswordResetApi";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function PasswordResetForm() {
|
||||||
|
const { t } = useTranslation("passwordReset");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleRequest = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
await requestPasswordResetApi(email);
|
||||||
|
setSuccess(t("success.resetRequested")); // passwordReset:success.resetRequested
|
||||||
|
showToast({ type: "success", tKey: "toast:success.resetRequested" });
|
||||||
|
setTimeout(() => navigate("/login", { replace: true }), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err?.status === 429 ||
|
||||||
|
err?.message?.toLowerCase().includes("rate limit") ||
|
||||||
|
err?.data?.error === "RATE_LIMITED"
|
||||||
|
) {
|
||||||
|
setError(t("error.rateLimited")); // passwordReset:error.rateLimited
|
||||||
|
showToast({ type: "error", tKey: "toast:passwordResetRateLimited" });
|
||||||
|
setTimeout(() => navigate("/login", { replace: true }), 1500);
|
||||||
|
} else {
|
||||||
|
setError(t("error.generic")); // passwordReset:error.generic
|
||||||
|
showToast({ type: "error", tKey: "toast:passwordResetGenericError" });
|
||||||
|
setTimeout(() => navigate("/login", { replace: true }), 1500);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleRequest} className="space-y-6" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("label.email")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
|
||||||
|
placeholder={t("placeholder.email")}
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? t("button.sending") : t("button.sendLink")}
|
||||||
|
</button>
|
||||||
|
{success && <p className="mt-4 text-center text-green-500 text-sm" aria-live="polite">{success}</p>}
|
||||||
|
{error && <p className="mt-4 text-center text-red-500 text-sm" aria-live="polite">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { validatePassword } from "../hooks/usePasswordReset";
|
||||||
|
import { resetPasswordApi } from "../api/usePasswordResetApi";
|
||||||
|
import { showToast } from "../../toast/toastUtils";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function PasswordResetSetNewPasswordForm({ token }) {
|
||||||
|
const { t } = useTranslation("passwordReset");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const rules = validatePassword(newPassword);
|
||||||
|
|
||||||
|
const handleReset = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
if (!rules.valid) {
|
||||||
|
setError(t("error.passwordLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError(t("error.passwordsNoMatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await resetPasswordApi(token, newPassword);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSuccess(t("success.reset"));
|
||||||
|
showToast({ type: "success", message: t("success.reset") });
|
||||||
|
setTimeout(() => navigate("/login", { replace: true }), 1500);
|
||||||
|
} else {
|
||||||
|
setError(t("error.resetFailed"));
|
||||||
|
showToast({ type: "error", message: t("error.resetFailed") });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("error.resetFailed"));
|
||||||
|
showToast({ type: "error", message: t("error.resetFailed") });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleReset} className="space-y-6" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("label.newPassword")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
|
||||||
|
placeholder={t("placeholder.newPassword")}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
aria-describedby="password-guideline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("label.confirmPassword")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-blue-900 placeholder-gray-400"
|
||||||
|
placeholder={t("placeholder.confirmPassword")}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
id="show-password"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={e => setShowPassword(e.target.checked)}
|
||||||
|
className="accent-blue-600"
|
||||||
|
aria-checked={showPassword}
|
||||||
|
/>
|
||||||
|
<label htmlFor="show-password" className="text-sm text-gray-700">
|
||||||
|
{t("label.showPassword")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="password-guideline"
|
||||||
|
className="border-l-4 border-blue-300 bg-blue-50 p-3 mb-2 rounded text-sm"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="font-semibold mb-2 text-blue-700">{t("guideline.title")}</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<PasswordRule
|
||||||
|
passed={rules.length}
|
||||||
|
text={t("guideline.length")}
|
||||||
|
/>
|
||||||
|
<PasswordRule
|
||||||
|
passed={rules.lowercase}
|
||||||
|
text={t("guideline.lowercase")}
|
||||||
|
/>
|
||||||
|
<PasswordRule
|
||||||
|
passed={rules.uppercase}
|
||||||
|
text={t("guideline.uppercase")}
|
||||||
|
/>
|
||||||
|
<PasswordRule
|
||||||
|
passed={rules.number}
|
||||||
|
text={t("guideline.number")}
|
||||||
|
/>
|
||||||
|
<PasswordRule
|
||||||
|
passed={rules.special}
|
||||||
|
text={t("guideline.special")}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white bg-gradient-to-r from-blue-700 via-blue-500 to-blue-400 hover:from-blue-800 hover:to-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 transition-all duration-200"
|
||||||
|
disabled={loading || !rules.valid || newPassword !== confirmPassword}
|
||||||
|
aria-disabled={loading || !rules.valid || newPassword !== confirmPassword}
|
||||||
|
>
|
||||||
|
{loading ? t("button.resetting") : t("button.reset")}
|
||||||
|
</button>
|
||||||
|
{success && <p className="mt-4 text-center text-green-500 text-sm" aria-live="polite">{success}</p>}
|
||||||
|
{error && <p className="mt-4 text-center text-red-500 text-sm" aria-live="polite">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordRule({ passed, text }) {
|
||||||
|
return (
|
||||||
|
<li className={`flex items-center gap-2 ${passed ? "text-green-600" : "text-gray-500"}`}>
|
||||||
|
{passed ? (
|
||||||
|
<span aria-label="passed" role="img">✔️</span>
|
||||||
|
) : (
|
||||||
|
<span aria-label="not passed" role="img">❌</span>
|
||||||
|
)}
|
||||||
|
<span>{text}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/features/password-reset/hooks/usePasswordReset.js
Normal file
19
src/features/password-reset/hooks/usePasswordReset.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { log } from "../../../utils/logger";
|
||||||
|
|
||||||
|
export function validatePassword(password) {
|
||||||
|
const length = password.length >= 8;
|
||||||
|
const lowercase = /[a-z]/.test(password);
|
||||||
|
const uppercase = /[A-Z]/.test(password);
|
||||||
|
const number = /[0-9]/.test(password);
|
||||||
|
const special = /[^A-Za-z0-9]/.test(password);
|
||||||
|
const result = {
|
||||||
|
valid: length && lowercase && uppercase && number && special,
|
||||||
|
length,
|
||||||
|
lowercase,
|
||||||
|
uppercase,
|
||||||
|
number,
|
||||||
|
special
|
||||||
|
};
|
||||||
|
log("validatePassword called", { passwordLength: password.length, result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user