Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
34
web/maplefile-frontend/.claudeignore
Normal file
34
web/maplefile-frontend/.claudeignore
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Frontend-specific Claude Code ignore file
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
*.local
|
||||
|
||||
# Environment
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Production
|
||||
.vercel
|
||||
.netlify
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.eslintcache
|
||||
25
web/maplefile-frontend/.crev-config.yaml
Normal file
25
web/maplefile-frontend/.crev-config.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Configuration for the crev tool
|
||||
# for more info see: https://crevcli.com/docs
|
||||
|
||||
# specify your CREV API key (necessary for review command) ! this overwrites the value you specify in the environment variable
|
||||
# you can get one on: https://crevcli.com/api-key
|
||||
crev_api_key: # ex. csk_8e796a8f6fdb15f0902eee0d4138b9d5975e244e6cc61ef502feaf37af24c7cb
|
||||
# specify the prefixes of files and directories to ignore (by default common configuration files are ignored)
|
||||
ignore-pre: [
|
||||
data,
|
||||
LICENSE,
|
||||
private.md,
|
||||
private_oauth2_flow.md,
|
||||
.env,
|
||||
.env.prod,
|
||||
maplefile-frontend,
|
||||
Dockerfile,
|
||||
dev.Dockerfile,
|
||||
node_modules,
|
||||
ios,
|
||||
android,
|
||||
] # ex. [tests, readme.md, scripts]
|
||||
# specify the extensions of files to ignore
|
||||
ignore-ext: [.md, .yml] # ex. [.go, .py, .js]
|
||||
# specify the extensions of files to include
|
||||
include-ext: [.js, .jsx, .ts, .tsx] # ex. [.go, .py, .js]
|
||||
17
web/maplefile-frontend/.env.development
Normal file
17
web/maplefile-frontend/.env.development
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# FILE: .env.development
|
||||
|
||||
# API Configuration
|
||||
VITE_API_DOMAIN=127.0.0.1:8000
|
||||
VITE_API_PROTOCOL=http
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_WWW_DOMAIN=localhost:3000
|
||||
VITE_WWW_PROTOCOL=http
|
||||
|
||||
# Upload Configuration (if needed)
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_IN_BYTES=5242880
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_ERROR_MESSAGE=File size must be less than 5MB
|
||||
|
||||
# Development mode flag
|
||||
VITE_DEV_MODE=true
|
||||
18
web/maplefile-frontend/.env.development.sample
Normal file
18
web/maplefile-frontend/.env.development.sample
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# FILE: .env.development.sample
|
||||
# Copy this file to .env.development and configure for your local environment
|
||||
|
||||
# API Configuration
|
||||
VITE_API_DOMAIN=127.0.0.1:8000
|
||||
VITE_API_PROTOCOL=http
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_WWW_DOMAIN=localhost:3000
|
||||
VITE_WWW_PROTOCOL=http
|
||||
|
||||
# Upload Configuration (if needed)
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_IN_BYTES=5242880
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_ERROR_MESSAGE=File size must be less than 5MB
|
||||
|
||||
# Development mode flag
|
||||
VITE_DEV_MODE=true
|
||||
21
web/maplefile-frontend/.env.example
Normal file
21
web/maplefile-frontend/.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# FILE: .env.example
|
||||
# Copy this file to .env.development or .env.production and configure values
|
||||
|
||||
# API Configuration
|
||||
# Backend API base URL (protocol + domain, without /api/v1 path)
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Legacy API configuration (optional)
|
||||
VITE_API_DOMAIN=127.0.0.1:8000
|
||||
VITE_API_PROTOCOL=http
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_WWW_DOMAIN=localhost:5174
|
||||
VITE_WWW_PROTOCOL=http
|
||||
|
||||
# Upload Configuration
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_IN_BYTES=5242880
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_ERROR_MESSAGE=File size must be less than 5MB
|
||||
|
||||
# Development mode flag
|
||||
VITE_DEV_MODE=false
|
||||
19
web/maplefile-frontend/.env.production
Normal file
19
web/maplefile-frontend/.env.production
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# FILE: .env.production
|
||||
# MapleFile Frontend - Production Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
VITE_API_DOMAIN=maplefile.ca
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_API_BASE_URL=https://maplefile.ca
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_WWW_DOMAIN=maplefile.ca
|
||||
VITE_WWW_PROTOCOL=https
|
||||
|
||||
# Upload Configuration (if needed)
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_IN_BYTES=5242880
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_ERROR_MESSAGE=File size must be less than 5MB
|
||||
|
||||
# Development mode flag
|
||||
VITE_DEV_MODE=false
|
||||
18
web/maplefile-frontend/.env.production.sample
Normal file
18
web/maplefile-frontend/.env.production.sample
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# FILE: .env.production.sample
|
||||
# Copy this file to .env.production and configure for your production environment
|
||||
|
||||
# API Configuration
|
||||
VITE_API_DOMAIN=mapleopentech.net
|
||||
VITE_API_PROTOCOL=https
|
||||
VITE_API_BASE_URL=https://mapleopentech.net
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_WWW_DOMAIN=maplefile.ca
|
||||
VITE_WWW_PROTOCOL=https
|
||||
|
||||
# Upload Configuration (if needed)
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_IN_BYTES=5242880
|
||||
VITE_IMAGE_UPLOAD_MAX_FILESIZE_ERROR_MESSAGE=File size must be less than 5MB
|
||||
|
||||
# Development mode flag
|
||||
VITE_DEV_MODE=false
|
||||
33
web/maplefile-frontend/.gitignore
vendored
Normal file
33
web/maplefile-frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment files with secrets should use .local suffix (e.g., .env.development.local)
|
||||
# .env.development and .env.production are tracked for convenience
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Project specific
|
||||
_md/*
|
||||
|
||||
# Build artifacts
|
||||
public/version.json
|
||||
67
web/maplefile-frontend/README.md
Normal file
67
web/maplefile-frontend/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# ⚛️ MapleFile Frontend (React + Vite)
|
||||
|
||||
End-to-end encrypted file storage frontend built with React 19 and Vite.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy environment template
|
||||
cp .env.development.sample .env.development
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend runs at **http://localhost:5173**
|
||||
|
||||
### Production Setup
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.production.sample .env.production
|
||||
|
||||
# Edit .env.production with your production settings
|
||||
# At minimum, set: VITE_API_BASE_URL
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build locally
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## ⚙️ Environment Configuration
|
||||
|
||||
Environment files are **not tracked in git**. Use the `.sample` files as templates:
|
||||
|
||||
- **`.env.development.sample`** → Copy to `.env.development` (local dev)
|
||||
- **`.env.production.sample`** → Copy to `.env.production` (production build)
|
||||
|
||||
**Key variables:**
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `VITE_API_BASE_URL` | Backend API endpoint | `http://localhost:8000` (dev) |
|
||||
| `VITE_DEV_MODE` | Development mode flag | `true` (dev) |
|
||||
|
||||
### Session Persistence
|
||||
|
||||
Users can choose their session persistence preference via the "Keep me logged in" checkbox on the login page:
|
||||
|
||||
- **Checked** - Uses `localStorage` to stay logged in after closing browser
|
||||
- **Unchecked** - Uses `sessionStorage` for session-only persistence (logged out on browser close)
|
||||
|
||||
The preference is stored and remembered for future logins.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Found a bug? Want a feature to improve MapleFile Frontend? Please create an [issue](https://codeberg.org/mapleopentech/monorepo/issues/new).
|
||||
|
||||
## 📝 License
|
||||
|
||||
This application is licensed under the [**GNU Affero General Public License v3.0**](https://opensource.org/license/agpl-v3). See [LICENSE](../../LICENSE) for more information.
|
||||
70
web/maplefile-frontend/Taskfile.yml
Normal file
70
web/maplefile-frontend/Taskfile.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
# Development task to start the local development server
|
||||
dev:
|
||||
desc: "Start the development server with hot module replacement"
|
||||
cmds:
|
||||
- npm run dev
|
||||
|
||||
# Production build task
|
||||
build:
|
||||
desc: "Build the production version of the project"
|
||||
cmds:
|
||||
# Build the project using Vite's build command
|
||||
- npm run build
|
||||
|
||||
# Deployment task for production server (worker-9)
|
||||
deploy:
|
||||
desc: "Deploy frontend to production server via SSH"
|
||||
cmds:
|
||||
- |
|
||||
echo "🚀 Deploying MapleFile Frontend to Production..."
|
||||
echo ""
|
||||
echo "⚠️ This task requires manual deployment on worker-9"
|
||||
echo ""
|
||||
echo "SSH to worker-9 and run:"
|
||||
echo " ssh dockeradmin@<worker-9-ip>"
|
||||
echo " ~/deploy-frontend.sh"
|
||||
echo ""
|
||||
echo "Or use the deployment script directly:"
|
||||
echo " cd /var/www/monorepo"
|
||||
echo " git pull origin main"
|
||||
echo " cd web/maplefile-frontend"
|
||||
echo " npm install"
|
||||
echo " npm run build"
|
||||
echo ""
|
||||
echo "See: cloud/infrastructure/production/setup/11_maplefile_frontend.md"
|
||||
echo ""
|
||||
|
||||
# Remote deployment helper (requires SSH access to worker-9)
|
||||
deploy-remote:
|
||||
desc: "Deploy to production server via SSH (requires worker-9 access)"
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "$WORKER9_IP" ]; then
|
||||
echo "❌ Error: WORKER9_IP environment variable not set"
|
||||
echo "Usage: WORKER9_IP=<ip-address> task deploy-remote"
|
||||
exit 1
|
||||
fi
|
||||
echo "🚀 Deploying to worker-9 ($WORKER9_IP)..."
|
||||
ssh dockeradmin@$WORKER9_IP "cd /var/www/monorepo && git pull origin main && cd web/maplefile-frontend && npm install && npm run build"
|
||||
echo "✅ Deployment complete!"
|
||||
|
||||
# Optional: Lint and type-check task
|
||||
lint:
|
||||
desc: "Run ESLint and TypeScript type checking"
|
||||
cmds:
|
||||
- npm run lint
|
||||
- npm run typecheck
|
||||
|
||||
# Optional: Run tests
|
||||
test:
|
||||
desc: "Run project tests"
|
||||
cmds:
|
||||
- npm run test
|
||||
|
||||
undelast:
|
||||
desc: Undue last commit which was not pushed. Special thanks to https://www.nobledesktop.com/learn/git/undo-changes.
|
||||
cmds:
|
||||
- git reset --soft HEAD~
|
||||
33
web/maplefile-frontend/eslint.config.js
Normal file
33
web/maplefile-frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
26
web/maplefile-frontend/index.html
Normal file
26
web/maplefile-frontend/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Security Headers (Defense in Depth - Backend should also set HTTP headers) -->
|
||||
<!-- Note: connect-src includes localhost:8000 (API), localhost:8334 (MinIO/S3 dev), and production S3 endpoints -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' http://localhost:8000 http://localhost:8334 https://*.digitaloceanspaces.com https://*.amazonaws.com ws://localhost:*; frame-ancestors 'none'; base-uri 'self'; form-action 'self';">
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
||||
<meta http-equiv="X-Frame-Options" content="DENY">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<meta http-equiv="Permissions-Policy" content="geolocation=(), microphone=(), camera=()">
|
||||
|
||||
<!-- SEO and Social Meta Tags -->
|
||||
<meta name="description" content="MapleFile - Secure End-to-End Encrypted File Storage">
|
||||
<meta name="theme-color" content="#1e40af">
|
||||
|
||||
<title>MapleFile - Secure File Storage</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5148
web/maplefile-frontend/package-lock.json
generated
Normal file
5148
web/maplefile-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
web/maplefile-frontend/package.json
Normal file
41
web/maplefile-frontend/package.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "maplefile-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/generate-version.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"axios": "^1.10.0",
|
||||
"inversify": "^7.5.2",
|
||||
"libsodium-wrappers-sumo": "^0.7.15",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.6.2",
|
||||
"recharts": "^3.1.0",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
6
web/maplefile-frontend/postcss.config.js
Normal file
6
web/maplefile-frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
72
web/maplefile-frontend/scripts/generate-version.js
Executable file
72
web/maplefile-frontend/scripts/generate-version.js
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate version information for the build
|
||||
* Captures git hash, build timestamp, and version
|
||||
*/
|
||||
|
||||
/* eslint-env node */
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import process from 'process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
try {
|
||||
// Get git information
|
||||
const gitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
||||
const gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
||||
|
||||
// Get timestamp
|
||||
const buildTime = new Date().toISOString();
|
||||
|
||||
// Read package.json version
|
||||
const packageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const packageJson = JSON.parse(
|
||||
execSync(`cat ${packageJsonPath}`, { encoding: 'utf-8' })
|
||||
);
|
||||
|
||||
const versionInfo = {
|
||||
version: packageJson.version,
|
||||
gitHash,
|
||||
gitBranch,
|
||||
buildTime,
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
|
||||
// Ensure public directory exists
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
|
||||
// Write to public directory so it's accessible at runtime
|
||||
const outputPath = join(publicDir, 'version.json');
|
||||
writeFileSync(outputPath, JSON.stringify(versionInfo, null, 2));
|
||||
|
||||
console.log('✅ Version information generated:');
|
||||
console.log(JSON.stringify(versionInfo, null, 2));
|
||||
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to generate version info:', error.message);
|
||||
|
||||
// Create fallback version info
|
||||
const fallbackVersion = {
|
||||
version: '0.0.0',
|
||||
gitHash: 'unknown',
|
||||
gitBranch: 'unknown',
|
||||
buildTime: new Date().toISOString(),
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
|
||||
// Ensure public directory exists
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
|
||||
const outputPath = join(publicDir, 'version.json');
|
||||
writeFileSync(outputPath, JSON.stringify(fallbackVersion, null, 2));
|
||||
|
||||
console.log('✅ Fallback version information generated');
|
||||
}
|
||||
42
web/maplefile-frontend/src/App.css
Normal file
42
web/maplefile-frontend/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;
|
||||
}
|
||||
124
web/maplefile-frontend/src/App.jsx
Normal file
124
web/maplefile-frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// File: src/App.jsx
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
|
||||
import { ServiceProvider } from "./services/Services";
|
||||
|
||||
// Front-facing pages
|
||||
import IndexPage from "./pages/Anonymous/Index/IndexPage";
|
||||
import DownloadPage from "./pages/Anonymous/Download/DownloadPage";
|
||||
|
||||
// Registration pages
|
||||
import Register from "./pages/Anonymous/Register/Register";
|
||||
import RecoveryCode from "./pages/Anonymous/Register/RecoveryCode";
|
||||
import VerifyEmail from "./pages/Anonymous/Register/VerifyEmail";
|
||||
import VerifySuccess from "./pages/Anonymous/Register/VerifySuccess";
|
||||
|
||||
// Login pages
|
||||
import RequestOTT from "./pages/Anonymous/Login/RequestOTT";
|
||||
import VerifyOTT from "./pages/Anonymous/Login/VerifyOTT";
|
||||
import CompleteLogin from "./pages/Anonymous/Login/CompleteLogin";
|
||||
import SessionExpired from "./pages/Anonymous/Login/SessionExpired";
|
||||
|
||||
// Recovery pages
|
||||
import InitiateRecovery from "./pages/Anonymous/Recovery/InitiateRecovery";
|
||||
import VerifyRecovery from "./pages/Anonymous/Recovery/VerifyRecovery";
|
||||
import CompleteRecovery from "./pages/Anonymous/Recovery/CompleteRecovery";
|
||||
|
||||
// User pages
|
||||
import Dashboard from "./pages/User/Dashboard/Dashboard";
|
||||
import MeDetail from "./pages/User/Me/Detail";
|
||||
import DeleteAccount from "./pages/User/Me/DeleteAccount";
|
||||
import ExportData from "./pages/User/Me/ExportData";
|
||||
import BlockedUsers from "./pages/User/Me/BlockedUsers";
|
||||
import Help from "./pages/User/Help/Help";
|
||||
|
||||
// Collection & File Manager
|
||||
import FileManagerIndex from "./pages/User/FileManager/FileManagerIndex";
|
||||
import CollectionCreate from "./pages/User/FileManager/Collections/CollectionCreate";
|
||||
import CollectionDetails from "./pages/User/FileManager/Collections/CollectionDetails";
|
||||
import CollectionShare from "./pages/User/FileManager/Collections/CollectionShare";
|
||||
import CollectionEdit from "./pages/User/FileManager/Collections/CollectionEdit";
|
||||
import FileUpload from "./pages/User/FileManager/Files/FileUpload";
|
||||
import FileDetails from "./pages/User/FileManager/Files/FileDetails";
|
||||
|
||||
// Tags
|
||||
import TagList from "./pages/User/Tags/TagList";
|
||||
import TagCreate from "./pages/User/Tags/TagCreate";
|
||||
import TagEdit from "./pages/User/Tags/TagEdit";
|
||||
import TagDelete from "./pages/User/Tags/TagDelete";
|
||||
import TagSearch from "./pages/User/Tags/TagSearch";
|
||||
import TagSearchResults from "./pages/User/Tags/TagSearchResults";
|
||||
|
||||
// Styles
|
||||
const styles = {
|
||||
app: {
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
// Main App component
|
||||
function App() {
|
||||
return (
|
||||
<ServiceProvider>
|
||||
<Router>
|
||||
<div style={styles.app}>
|
||||
<Routes>
|
||||
{/* Front-facing pages */}
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
<Route path="/download" element={<DownloadPage />} />
|
||||
|
||||
{/* Registration routes */}
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/register/recovery" element={<RecoveryCode />} />
|
||||
<Route path="/register/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/register/verify-success" element={<VerifySuccess />} />
|
||||
|
||||
{/* Login routes */}
|
||||
<Route path="/login" element={<RequestOTT />} />
|
||||
<Route path="/login/verify-ott" element={<VerifyOTT />} />
|
||||
<Route path="/login/complete" element={<CompleteLogin />} />
|
||||
<Route path="/session-expired" element={<SessionExpired />} />
|
||||
|
||||
{/* Recovery routes */}
|
||||
<Route path="/recovery" element={<InitiateRecovery />} />
|
||||
<Route path="/recovery/initiate" element={<InitiateRecovery />} />
|
||||
<Route path="/recovery/verify" element={<VerifyRecovery />} />
|
||||
<Route path="/recovery/complete" element={<CompleteRecovery />} />
|
||||
|
||||
{/* User routes */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/me" element={<MeDetail />} />
|
||||
<Route path="/me/delete-account" element={<DeleteAccount />} />
|
||||
<Route path="/me/export-data" element={<ExportData />} />
|
||||
<Route path="/me/blocked-users" element={<BlockedUsers />} />
|
||||
<Route path="/profile" element={<MeDetail />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
|
||||
{/* File Manager Routes */}
|
||||
<Route path="/file-manager" element={<FileManagerIndex />} />
|
||||
<Route path="/file-manager/collections/create" element={<CollectionCreate />} />
|
||||
<Route path="/file-manager/collections/:collectionId" element={<CollectionDetails />} />
|
||||
<Route path="/file-manager/collections/:collectionId/share" element={<CollectionShare />} />
|
||||
<Route path="/file-manager/collections/:collectionId/edit" element={<CollectionEdit />} />
|
||||
<Route path="/file-manager/upload" element={<FileUpload />} />
|
||||
<Route path="/file-manager/files/:fileId" element={<FileDetails />} />
|
||||
|
||||
{/* Tag Routes */}
|
||||
<Route path="/me/tags" element={<TagList />} />
|
||||
<Route path="/me/tags/create" element={<TagCreate />} />
|
||||
<Route path="/me/tags/:tagId/edit" element={<TagEdit />} />
|
||||
<Route path="/me/tags/:tagId/delete" element={<TagDelete />} />
|
||||
<Route path="/me/tags/search" element={<TagSearch />} />
|
||||
<Route path="/me/tags/search/results" element={<TagSearchResults />} />
|
||||
|
||||
{/* Redirect any unknown routes to home */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</ServiceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
558
web/maplefile-frontend/src/components/Layout/Layout.jsx
Normal file
558
web/maplefile-frontend/src/components/Layout/Layout.jsx
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
// File Path: web/frontend/src/components/Layout/Layout.jsx
|
||||
// Fixed Layout Component - Mobile Menu Now Works Properly
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import TopNavbar from "./TopNavbar";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { useInactivityTimeout } from "../../hooks/useInactivityTimeout";
|
||||
import { useAuth } from "../../services/Services";
|
||||
import { useUIXTheme } from "../UIX/themes/useUIXTheme";
|
||||
|
||||
function Layout({ children }) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [isAndroid, setIsAndroid] = useState(false);
|
||||
const [iosKeyboardHeight, setIosKeyboardHeight] = useState(0);
|
||||
const [androidKeyboardVisible, setAndroidKeyboardVisible] = useState(false);
|
||||
|
||||
const { meManager } = useAuth();
|
||||
const { switchTheme, getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Initialize inactivity timeout - auto-logout after 15 minutes of inactivity
|
||||
// Will redirect to /logout when timeout is reached
|
||||
useInactivityTimeout(15 * 60 * 1000, true);
|
||||
|
||||
// Load user's theme preference on mount (for authenticated pages)
|
||||
useEffect(() => {
|
||||
const loadThemePreference = async () => {
|
||||
try {
|
||||
const userProfile = await meManager.getCurrentUser();
|
||||
if (userProfile?.themePreference) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Layout: Applying user's saved theme preference:", userProfile.themePreference);
|
||||
}
|
||||
switchTheme(userProfile.themePreference);
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Layout: Failed to load theme preference:", error);
|
||||
}
|
||||
// Don't block the UI if theme loading fails
|
||||
}
|
||||
};
|
||||
|
||||
loadThemePreference();
|
||||
}, [meManager, switchTheme]); // Run when meManager is available
|
||||
|
||||
// iOS detection function
|
||||
const detectIOS = () => {
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
};
|
||||
|
||||
// Android detection function
|
||||
const detectAndroid = () => {
|
||||
return /Android/.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
// Enhanced device detection with iOS and Android support (memoized)
|
||||
const updateDeviceType = useCallback(() => {
|
||||
const width = window.innerWidth;
|
||||
const mobile = width < 768;
|
||||
const tablet = width >= 768 && width < 1024;
|
||||
const ios = detectIOS();
|
||||
const android = detectAndroid();
|
||||
|
||||
setIsMobile(mobile);
|
||||
setIsTablet(tablet);
|
||||
setIsIOS(ios);
|
||||
setIsAndroid(android);
|
||||
|
||||
return { mobile, tablet, ios, android };
|
||||
}, []);
|
||||
|
||||
// Check localStorage for sidebar collapsed state
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem("sidebarCollapsed");
|
||||
const { mobile } = updateDeviceType();
|
||||
|
||||
if (savedState === "true" && !mobile) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}, [updateDeviceType]);
|
||||
|
||||
// iOS-specific optimizations
|
||||
useEffect(() => {
|
||||
if (!isIOS) return;
|
||||
|
||||
// Fix iOS viewport height issues
|
||||
const setIOSViewportHeight = () => {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty("--vh", `${vh}px`);
|
||||
};
|
||||
|
||||
// iOS keyboard handling
|
||||
const handleIOSKeyboard = () => {
|
||||
const initialViewport =
|
||||
window.visualViewport?.height || window.innerHeight;
|
||||
|
||||
const onViewportChange = () => {
|
||||
if (window.visualViewport) {
|
||||
const currentHeight = window.visualViewport.height;
|
||||
const keyboardHeight = initialViewport - currentHeight;
|
||||
setIosKeyboardHeight(keyboardHeight > 150 ? keyboardHeight : 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", onViewportChange);
|
||||
return () =>
|
||||
window.visualViewport.removeEventListener("resize", onViewportChange);
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent iOS bounce/overscroll
|
||||
const preventBounce = (e) => {
|
||||
if (e.target.closest(".sidebar-content, .main-content")) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Apply iOS fixes
|
||||
setIOSViewportHeight();
|
||||
const keyboardCleanup = handleIOSKeyboard();
|
||||
|
||||
// Prevent zoom on double tap
|
||||
let lastTouchEnd = 0;
|
||||
const preventZoom = (e) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
e.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener("resize", setIOSViewportHeight);
|
||||
document.addEventListener("touchend", preventZoom, { passive: false });
|
||||
document.addEventListener("touchmove", preventBounce, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", setIOSViewportHeight);
|
||||
document.removeEventListener("touchend", preventZoom);
|
||||
document.removeEventListener("touchmove", preventBounce);
|
||||
keyboardCleanup?.();
|
||||
};
|
||||
}, [isIOS]);
|
||||
|
||||
// Android-specific optimizations
|
||||
useEffect(() => {
|
||||
if (!isAndroid) return;
|
||||
|
||||
// Android viewport height fixes for Chrome Mobile
|
||||
const setAndroidViewportHeight = () => {
|
||||
// Chrome on Android has dynamic toolbar behavior
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty("--android-vh", `${vh}px`);
|
||||
};
|
||||
|
||||
// Android keyboard detection (different approach than iOS)
|
||||
const handleAndroidKeyboard = () => {
|
||||
const initialHeight = window.innerHeight;
|
||||
let resizeTimer;
|
||||
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
const currentHeight = window.innerHeight;
|
||||
const heightDifference = initialHeight - currentHeight;
|
||||
|
||||
// Android keyboard typically reduces viewport by 150px+
|
||||
const keyboardVisible = heightDifference > 150;
|
||||
setAndroidKeyboardVisible(keyboardVisible);
|
||||
|
||||
// Update viewport height
|
||||
setAndroidViewportHeight();
|
||||
}, 100); // Debounce for performance
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
clearTimeout(resizeTimer);
|
||||
};
|
||||
};
|
||||
|
||||
// Android Chrome scroll behavior optimization
|
||||
const optimizeAndroidScroll = () => {
|
||||
// Prevent overscroll in Android Chrome
|
||||
const preventOverscroll = (e) => {
|
||||
const target = e.target;
|
||||
const scrollableParent = target.closest(
|
||||
".scrollable, .main-content, .sidebar-content",
|
||||
);
|
||||
|
||||
if (!scrollableParent) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollableParent;
|
||||
const isAtTop = scrollTop === 0;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
|
||||
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Android-specific touch optimizations
|
||||
const optimizeTouch = () => {
|
||||
// Improve scrolling performance on Android
|
||||
document.body.style.touchAction = "pan-y";
|
||||
document.body.style.overscrollBehavior = "none";
|
||||
};
|
||||
|
||||
optimizeTouch();
|
||||
document.addEventListener("wheel", preventOverscroll, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventOverscroll);
|
||||
};
|
||||
};
|
||||
|
||||
// Apply Android fixes
|
||||
setAndroidViewportHeight();
|
||||
const keyboardCleanup = handleAndroidKeyboard();
|
||||
const scrollCleanup = optimizeAndroidScroll();
|
||||
|
||||
return () => {
|
||||
keyboardCleanup?.();
|
||||
scrollCleanup?.();
|
||||
};
|
||||
}, [isAndroid]);
|
||||
|
||||
// Listen for storage changes to sync collapsed state
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const savedState = localStorage.getItem("sidebarCollapsed");
|
||||
setSidebarCollapsed(savedState === "true");
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
// Also listen for custom event for same-tab updates
|
||||
window.addEventListener("sidebarCollapsedChanged", handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener(
|
||||
"sidebarCollapsedChanged",
|
||||
handleStorageChange,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// FIXED: Resize handler with proper dependencies and refs to prevent infinite loops
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
const wasMobile = isMobile;
|
||||
const mobile = width < 768;
|
||||
const tablet = width >= 768 && width < 1024;
|
||||
|
||||
setIsMobile(mobile);
|
||||
setIsTablet(tablet);
|
||||
setIsIOS(detectIOS());
|
||||
setIsAndroid(detectAndroid());
|
||||
|
||||
// Only close sidebar when transitioning FROM desktop TO mobile
|
||||
// Use callback to get current sidebar state to avoid stale closure
|
||||
setIsSidebarOpen(currentIsOpen => {
|
||||
if (!wasMobile && mobile && currentIsOpen) {
|
||||
return false;
|
||||
}
|
||||
return currentIsOpen;
|
||||
});
|
||||
|
||||
// Reset collapsed state when becoming mobile
|
||||
if (!wasMobile && mobile) {
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial device detection
|
||||
handleResize();
|
||||
|
||||
// Only listen to actual window resize events
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isMobile]); // Include isMobile but use functional state updates to avoid loops
|
||||
|
||||
const handleMenuToggle = useCallback(() => {
|
||||
setIsSidebarOpen(current => !current);
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback(() => {
|
||||
setIsSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// Handle collapse toggle for desktop view
|
||||
const handleCollapseToggle = useCallback(() => {
|
||||
setSidebarCollapsed(current => {
|
||||
const newCollapsedState = !current;
|
||||
localStorage.setItem("sidebarCollapsed", newCollapsedState.toString());
|
||||
window.dispatchEvent(new Event("sidebarCollapsedChanged"));
|
||||
return newCollapsedState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Calculate sidebar margins based on device type and state
|
||||
const getSidebarMargin = () => {
|
||||
if (isMobile) return "";
|
||||
|
||||
if (isTablet) {
|
||||
// Tablet: smaller sidebar widths
|
||||
return sidebarCollapsed ? "ml-12" : "ml-48";
|
||||
}
|
||||
|
||||
// Desktop: full sidebar widths
|
||||
return sidebarCollapsed ? "ml-16" : "ml-64";
|
||||
};
|
||||
|
||||
// Calculate responsive padding based on device
|
||||
const getContentPadding = () => {
|
||||
if (isMobile) {
|
||||
return "px-3 sm:px-4 py-4";
|
||||
}
|
||||
if (isTablet) {
|
||||
return "px-4 md:px-6 py-5";
|
||||
}
|
||||
// Desktop and larger screens
|
||||
return "px-4 sm:px-6 lg:px-8 py-6";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
min-h-screen ${getThemeClasses("bg-page")}
|
||||
${isIOS ? "ios-layout" : ""}
|
||||
${isAndroid ? "android-layout" : ""}
|
||||
`}
|
||||
style={{
|
||||
...(isIOS && {
|
||||
height: "calc(var(--vh, 1vh) * 100)",
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
paddingLeft: "env(safe-area-inset-left)",
|
||||
paddingRight: "env(safe-area-inset-right)",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
touchAction: "manipulation",
|
||||
}),
|
||||
...(isAndroid && {
|
||||
height: "calc(var(--android-vh, 1vh) * 100)",
|
||||
touchAction: "pan-y",
|
||||
overscrollBehavior: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Top Navigation Bar */}
|
||||
<TopNavbar
|
||||
onMenuToggle={handleMenuToggle}
|
||||
isMobile={isMobile}
|
||||
isTablet={isTablet}
|
||||
isIOS={isIOS}
|
||||
isAndroid={isAndroid}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onCollapseToggle={handleCollapseToggle}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={handleSidebarClose}
|
||||
isMobile={isMobile}
|
||||
isTablet={isTablet}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onCollapseToggle={handleCollapseToggle}
|
||||
isIOS={isIOS}
|
||||
isAndroid={isAndroid}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main
|
||||
className={`
|
||||
pt-[60px] transition-all duration-300 ease-in-out
|
||||
main-content scrollable
|
||||
${isIOS ? "ios-main-content" : ""}
|
||||
${isAndroid ? "android-main-content" : ""}
|
||||
`}
|
||||
style={{
|
||||
...(isIOS && {
|
||||
minHeight:
|
||||
iosKeyboardHeight > 0
|
||||
? `calc(var(--vh, 1vh) * 100 - 60px - ${iosKeyboardHeight}px)`
|
||||
: "calc(var(--vh, 1vh) * 100 - 60px)",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overflowY: "auto",
|
||||
}),
|
||||
...(isAndroid && {
|
||||
minHeight: androidKeyboardVisible
|
||||
? "calc(var(--android-vh, 1vh) * 50)"
|
||||
: "calc(var(--android-vh, 1vh) * 100 - 60px)",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overflowY: "auto",
|
||||
touchAction: "pan-y",
|
||||
overscrollBehavior: "contain",
|
||||
}),
|
||||
...(!isIOS &&
|
||||
!isAndroid && {
|
||||
minHeight: "calc(100vh - 60px)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Responsive margin based on device and sidebar state */}
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 ease-in-out
|
||||
${getSidebarMargin()}
|
||||
`}
|
||||
>
|
||||
{/* Content Container */}
|
||||
<div
|
||||
className={`
|
||||
${getContentPadding()}
|
||||
max-w-full
|
||||
${isIOS ? "ios-content-container" : ""}
|
||||
${isAndroid ? "android-content-container" : ""}
|
||||
${isMobile ? "min-h-[calc(100vh-120px)]" : "min-h-[calc(100vh-80px)]"}
|
||||
`}
|
||||
style={{
|
||||
...(isIOS &&
|
||||
isMobile && {
|
||||
minHeight:
|
||||
iosKeyboardHeight > 0
|
||||
? `calc(var(--vh, 1vh) * 100 - 180px - ${iosKeyboardHeight}px)`
|
||||
: "calc(var(--vh, 1vh) * 100 - 120px)",
|
||||
touchAction: "pan-y",
|
||||
}),
|
||||
...(isAndroid &&
|
||||
isMobile && {
|
||||
minHeight: androidKeyboardVisible
|
||||
? "calc(var(--android-vh, 1vh) * 40)"
|
||||
: "calc(var(--android-vh, 1vh) * 100 - 120px)",
|
||||
touchAction: "pan-y",
|
||||
overscrollBehavior: "contain",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Responsive container for content */}
|
||||
<div
|
||||
className={`
|
||||
w-full
|
||||
${!isMobile ? "max-w-[1400px] mx-auto" : ""}
|
||||
${isTablet ? "max-w-[900px] mx-auto" : ""}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile overlay when sidebar is open - REMOVED the redundant one that was here */}
|
||||
|
||||
{/* iOS and Android specific styles */}
|
||||
{(isIOS || isAndroid) && (
|
||||
<style>{`
|
||||
/* iOS Optimizations */
|
||||
.ios-layout {
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ios-main-content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ios-content-container * {
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@supports (-webkit-backdrop-filter: blur(10px)) {
|
||||
.ios-layout .bg-white {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Android Optimizations */
|
||||
.android-layout {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.android-main-content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.android-content-container {
|
||||
contain: layout style paint;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Android Chrome specific fixes */
|
||||
@media screen and (-webkit-device-pixel-ratio: 1) {
|
||||
.android-layout {
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Android keyboard adjustments */
|
||||
.android-layout.keyboard-visible {
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
/* Improve Android scroll performance */
|
||||
.android-layout .scrollable {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
transform: translateZ(0);
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
/* Android-specific button optimizations */
|
||||
.android-layout button {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Android Chrome address bar handling */
|
||||
@media screen and (max-width: 768px) {
|
||||
.android-layout {
|
||||
height: 100vh;
|
||||
height: calc(var(--android-vh, 1vh) * 100);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
315
web/maplefile-frontend/src/components/Layout/Sidebar.jsx
Normal file
315
web/maplefile-frontend/src/components/Layout/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
// File Path: web/frontend/src/components/Layout/Sidebar.jsx
|
||||
// UIX version - Theme-aware Responsive Sidebar Component for MapleFile
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useAuth } from "../../services/Services";
|
||||
import {
|
||||
HomeIcon,
|
||||
FolderIcon,
|
||||
CloudArrowUpIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../UIX";
|
||||
|
||||
function Sidebar({
|
||||
isOpen = false,
|
||||
onClose,
|
||||
sidebarCollapsed = false,
|
||||
onCollapseToggle,
|
||||
isMobile = false,
|
||||
isTablet = false,
|
||||
isIOS = false,
|
||||
isAndroid = false,
|
||||
}) {
|
||||
const { authManager, meManager } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Refs for cleanup
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Simple device type detection using props from Layout
|
||||
const isMobileOrTablet = useMemo(() => isMobile || isTablet, [isMobile, isTablet]);
|
||||
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Handle navigation and close sidebar on mobile
|
||||
const handleLinkClick = useCallback(
|
||||
(path) => {
|
||||
// Navigate first
|
||||
navigate(path);
|
||||
|
||||
// Then close sidebar on mobile/tablet
|
||||
if (isMobileOrTablet && onClose) {
|
||||
// Small delay for smooth transition
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
[navigate, isMobileOrTablet, onClose],
|
||||
);
|
||||
|
||||
// Fetch current user
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
if (!authManager.isAuthenticated()) {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await meManager.getCurrentUser();
|
||||
if (isMountedRef.current) {
|
||||
setCurrentUser(profile);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[Sidebar] Failed to fetch current user:", error);
|
||||
}
|
||||
if (isMountedRef.current) {
|
||||
setCurrentUser(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
// Redirect to login if unauthorized
|
||||
if (error.message?.includes("not authenticated")) {
|
||||
onUnauthorized();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [meManager, authManager, onUnauthorized]);
|
||||
|
||||
// Lock body scroll when sidebar is open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobileOrTablet || !isOpen) return;
|
||||
|
||||
// Save current scroll position
|
||||
const scrollY = window.scrollY;
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${scrollY}px`;
|
||||
document.body.style.width = "100%";
|
||||
|
||||
return () => {
|
||||
// Restore body scroll
|
||||
document.body.style.position = "";
|
||||
document.body.style.top = "";
|
||||
document.body.style.width = "";
|
||||
|
||||
// Only restore scroll position if we're still on the same page
|
||||
// This prevents inappropriate scroll restoration when navigating to new pages
|
||||
if (location.pathname === currentPath) {
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
};
|
||||
}, [isMobileOrTablet, isOpen, location.pathname]);
|
||||
|
||||
// Paths where sidebar should not be shown
|
||||
const hiddenPaths = useMemo(() => [
|
||||
"/",
|
||||
"/register",
|
||||
"/register/recovery",
|
||||
"/register/verify-email",
|
||||
"/register/verify-success",
|
||||
"/login",
|
||||
"/login/verify-ott",
|
||||
"/login/complete",
|
||||
"/session-expired",
|
||||
"/recovery",
|
||||
"/recovery/initiate",
|
||||
"/recovery/verify",
|
||||
"/recovery/complete",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
], []);
|
||||
|
||||
const shouldHideSidebar = useMemo(() =>
|
||||
hiddenPaths.some(
|
||||
(path) =>
|
||||
location.pathname === path ||
|
||||
(path !== "/" && location.pathname.startsWith(path)),
|
||||
),
|
||||
[hiddenPaths, location.pathname]
|
||||
);
|
||||
|
||||
const isActivePath = useCallback((path) => {
|
||||
// Exact match
|
||||
if (location.pathname === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For /me (Profile), only match exactly - don't match /me/tags/* or other /me/* paths
|
||||
if (path === "/me") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For /file-manager, only highlight if we're on a sub-path that's not in the menu
|
||||
if (path === "/file-manager") {
|
||||
const subPaths = ["/file-manager/upload"];
|
||||
const isOnSubPath = subPaths.some(subPath => location.pathname.startsWith(subPath));
|
||||
|
||||
// Only highlight File Manager if we're on /file-manager/* but NOT on a specific sub-menu item
|
||||
return location.pathname.startsWith("/file-manager/") && !isOnSubPath;
|
||||
}
|
||||
|
||||
// For other paths, use startsWith for sub-pages
|
||||
return location.pathname.startsWith(path + "/");
|
||||
}, [location.pathname]);
|
||||
|
||||
// MapleFile menu structure
|
||||
const menuSections = useMemo(() => [
|
||||
{
|
||||
label: "Main",
|
||||
items: [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: HomeIcon },
|
||||
{ path: "/file-manager", label: "File Manager", icon: FolderIcon },
|
||||
{ path: "/file-manager/upload", label: "Upload Files", icon: CloudArrowUpIcon },
|
||||
{ path: "/me/tags/search", label: "Search by Tags", icon: TagIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{ path: "/me", label: "Profile", icon: UserCircleIcon },
|
||||
{ path: "/logout", label: "Logout", icon: ArrowRightOnRectangleIcon },
|
||||
],
|
||||
},
|
||||
], []);
|
||||
|
||||
// Determine sidebar width
|
||||
const getSidebarWidth = useCallback(() => {
|
||||
if (isMobile) return "w-72";
|
||||
if (isTablet) return sidebarCollapsed ? "w-16" : "w-56";
|
||||
return sidebarCollapsed ? "w-16" : "w-64"; // Desktop
|
||||
}, [isMobile, isTablet, sidebarCollapsed]);
|
||||
|
||||
// Determine if labels should show
|
||||
const shouldShowLabels = useMemo(() => !sidebarCollapsed || isMobileOrTablet, [sidebarCollapsed, isMobileOrTablet]);
|
||||
|
||||
// Don't render if sidebar should be hidden
|
||||
if (shouldHideSidebar || isLoading || !currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay for mobile/tablet - Only show when sidebar is open */}
|
||||
{isMobileOrTablet && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-[999]"
|
||||
style={{ top: "60px" }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<div
|
||||
className={`
|
||||
fixed left-0 top-[60px] h-[calc(100vh-60px)]
|
||||
bg-gray-900 text-gray-200
|
||||
${getSidebarWidth()}
|
||||
transition-transform duration-300 ease-in-out
|
||||
overflow-y-auto overflow-x-hidden
|
||||
z-[1000]
|
||||
${
|
||||
// This is the key: On mobile/tablet, control visibility with transform
|
||||
isMobileOrTablet
|
||||
? isOpen
|
||||
? "transform translate-x-0"
|
||||
: "transform -translate-x-full"
|
||||
: "transform translate-x-0" // Always visible on desktop
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Sidebar Content */}
|
||||
<div className={isMobile ? "px-4 py-4" : "px-3 py-3"}>
|
||||
{/* Menu Sections */}
|
||||
{menuSections.map((section, sectionIndex) => (
|
||||
<div
|
||||
key={sectionIndex}
|
||||
className={sectionIndex < menuSections.length - 1 ? "mb-6" : ""}
|
||||
>
|
||||
{/* Section Label */}
|
||||
{shouldShowLabels && (
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-3">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items */}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActivePath(item.path);
|
||||
|
||||
return (
|
||||
<li key={itemIndex}>
|
||||
<button
|
||||
onClick={() => handleLinkClick(item.path)}
|
||||
className={`
|
||||
w-full
|
||||
relative flex items-center
|
||||
${shouldShowLabels ? "justify-start" : "justify-center"}
|
||||
${isMobile ? "px-4 py-3" : sidebarCollapsed && !isTablet ? "px-2 py-2" : "px-3 py-2"}
|
||||
rounded-md
|
||||
${isMobile ? "text-base" : "text-sm"}
|
||||
transition-all duration-200
|
||||
cursor-pointer
|
||||
${
|
||||
isActive
|
||||
? getThemeClasses("pagination-active")
|
||||
: "text-gray-300 hover:bg-gray-700"
|
||||
}
|
||||
`}
|
||||
title={!shouldShowLabels ? item.label : undefined}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${shouldShowLabels ? "" : "justify-center w-full"}`}
|
||||
>
|
||||
<Icon
|
||||
className={`
|
||||
${isMobile ? "h-6 w-6" : "h-5 w-5"}
|
||||
${shouldShowLabels ? "mr-3" : ""}
|
||||
flex-shrink-0
|
||||
`}
|
||||
/>
|
||||
{shouldShowLabels && (
|
||||
<span className="text-left">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
365
web/maplefile-frontend/src/components/Layout/TopNavbar.jsx
Normal file
365
web/maplefile-frontend/src/components/Layout/TopNavbar.jsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
// File Path: web/frontend/src/components/Layout/TopNavbar.jsx
|
||||
// UIX version - Theme-aware Top Navigation Bar
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { useAuth } from "../../services/Services";
|
||||
import {
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
UserCircleIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Modal, Button, useUIXTheme } from "../UIX";
|
||||
|
||||
function TopNavbar({
|
||||
onMenuToggle,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
isSidebarOpen,
|
||||
sidebarCollapsed,
|
||||
onCollapseToggle,
|
||||
}) {
|
||||
const { authManager, meManager } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Refs for cleanup
|
||||
const isMountedRef = useRef(true);
|
||||
const dropdownMenuTimer = useRef(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAccountDropdown, setShowAccountDropdown] = useState(false);
|
||||
const [showLogoutWarning, setShowLogoutWarning] = useState(false);
|
||||
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Determine if we're on mobile/tablet for slide menu
|
||||
const shouldShowMobileMenu = isMobile || isTablet;
|
||||
|
||||
// Fetch current user with proper dependencies
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
if (!authManager.isAuthenticated()) {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await meManager.getCurrentUser();
|
||||
if (isMountedRef.current) {
|
||||
setCurrentUser(profile);
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TopNavbar] Failed to fetch current user:", error);
|
||||
}
|
||||
if (isMountedRef.current) {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
// Redirect to login if unauthorized
|
||||
if (error.message?.includes("not authenticated")) {
|
||||
onUnauthorized();
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (dropdownMenuTimer.current) {
|
||||
clearTimeout(dropdownMenuTimer.current);
|
||||
}
|
||||
};
|
||||
}, [location.pathname, authManager, meManager, onUnauthorized]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest(".account-menu-container")) {
|
||||
setShowAccountDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showAccountDropdown) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
}, [showAccountDropdown]);
|
||||
|
||||
// Close dropdown when navigating
|
||||
useEffect(() => {
|
||||
setShowAccountDropdown(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Paths where navbar should not be shown
|
||||
const hiddenPaths = useMemo(() => [
|
||||
"/",
|
||||
"/register",
|
||||
"/index",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/verify",
|
||||
"/forgot-password",
|
||||
"/password-reset",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
], []);
|
||||
|
||||
const shouldHideNavbar = useMemo(() =>
|
||||
hiddenPaths.some(
|
||||
(path) =>
|
||||
location.pathname === path ||
|
||||
(path !== "/" && location.pathname.startsWith(path)),
|
||||
),
|
||||
[hiddenPaths, location.pathname]
|
||||
);
|
||||
|
||||
const handleMenuButtonClick = useCallback(() => {
|
||||
if (shouldShowMobileMenu) {
|
||||
// On mobile/tablet: toggle sidebar open/close
|
||||
onMenuToggle();
|
||||
} else {
|
||||
// On desktop: toggle sidebar collapse/expand
|
||||
onCollapseToggle();
|
||||
}
|
||||
}, [shouldShowMobileMenu, onMenuToggle, onCollapseToggle]);
|
||||
|
||||
const getMenuIcon = useCallback(() => {
|
||||
// Different icons based on context
|
||||
if (shouldShowMobileMenu) {
|
||||
// Mobile/Tablet: Show X when sidebar is open, hamburger when closed
|
||||
return isSidebarOpen ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
);
|
||||
} else {
|
||||
// Desktop: Always show hamburger for collapse/expand
|
||||
return <Bars3Icon className="h-5 w-5" />;
|
||||
}
|
||||
}, [shouldShowMobileMenu, isSidebarOpen]);
|
||||
|
||||
const getButtonTitle = useCallback(() => {
|
||||
if (shouldShowMobileMenu) {
|
||||
return isSidebarOpen ? "Close menu" : "Open menu";
|
||||
} else {
|
||||
return sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar";
|
||||
}
|
||||
}, [shouldShowMobileMenu, isSidebarOpen, sidebarCollapsed]);
|
||||
|
||||
const isActivePath = useCallback((path) => {
|
||||
return location.pathname.includes(path);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogoutConfirm = useCallback(async () => {
|
||||
try {
|
||||
setShowLogoutWarning(false);
|
||||
setShowAccountDropdown(false);
|
||||
navigate("/logout");
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TopNavbar] Logout navigation failed:", error);
|
||||
}
|
||||
window.location.href = "/logout";
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleAccountMenuEnter = useCallback(() => {
|
||||
if (!shouldShowMobileMenu) {
|
||||
clearTimeout(dropdownMenuTimer.current);
|
||||
setShowAccountDropdown(true);
|
||||
}
|
||||
}, [shouldShowMobileMenu]);
|
||||
|
||||
const handleAccountMenuLeave = useCallback(() => {
|
||||
if (!shouldShowMobileMenu) {
|
||||
dropdownMenuTimer.current = setTimeout(() => {
|
||||
setShowAccountDropdown(false);
|
||||
}, 200);
|
||||
}
|
||||
}, [shouldShowMobileMenu]);
|
||||
|
||||
const handleAccountMenuClick = useCallback(() => {
|
||||
if (shouldShowMobileMenu) {
|
||||
setShowAccountDropdown(!showAccountDropdown);
|
||||
}
|
||||
}, [shouldShowMobileMenu, showAccountDropdown]);
|
||||
|
||||
const handleDropdownClose = useCallback(() => {
|
||||
setShowAccountDropdown(false);
|
||||
}, []);
|
||||
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
setShowAccountDropdown(false);
|
||||
setShowLogoutWarning(true);
|
||||
}, []);
|
||||
|
||||
// Memoize user display name
|
||||
const userDisplayName = useMemo(() => {
|
||||
if (!currentUser) return "User";
|
||||
return {
|
||||
mobile: currentUser.firstName || "User",
|
||||
tablet: `Hi, ${currentUser.firstName || currentUser.email?.split("@")[0] || "User"}`,
|
||||
desktop: `Welcome, ${currentUser.firstName || currentUser.email}`,
|
||||
};
|
||||
}, [currentUser]);
|
||||
|
||||
if (shouldHideNavbar || isLoading || !currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed top-0 left-0 right-0 h-[60px] bg-gray-900 text-white flex items-center justify-between z-50 shadow-md">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center flex-1 min-w-0 px-4">
|
||||
{/* Menu Control Button - ALWAYS VISIBLE */}
|
||||
<button
|
||||
onClick={handleMenuButtonClick}
|
||||
className="p-2 rounded-md hover:bg-gray-700 transition-colors duration-200 flex items-center justify-center min-w-[44px] min-h-[44px]"
|
||||
title={getButtonTitle()}
|
||||
aria-label={getButtonTitle()}
|
||||
>
|
||||
{getMenuIcon()}
|
||||
</button>
|
||||
|
||||
{/* Logo Container - Adjust position based on screen size */}
|
||||
<div
|
||||
className={`
|
||||
flex-1 flex
|
||||
${isMobile ? "justify-center" : "justify-start ml-4"}
|
||||
`}
|
||||
>
|
||||
<img
|
||||
src="/img/compressed-logo.png"
|
||||
alt="MapleFile Logo"
|
||||
className="h-8 w-auto sm:h-9 md:h-10"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spacer for mobile to balance the layout */}
|
||||
{isMobile && <div className="w-[52px]" />}
|
||||
</div>
|
||||
|
||||
{/* Right Section - User Account Menu */}
|
||||
<div
|
||||
className="account-menu-container relative px-3 sm:px-5"
|
||||
onMouseEnter={handleAccountMenuEnter}
|
||||
onMouseLeave={handleAccountMenuLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleAccountMenuClick}
|
||||
className="flex items-center gap-1 text-xs sm:text-sm text-gray-300 hover:text-white hover:bg-gray-700 transition-colors duration-200 py-2 px-2 rounded-md"
|
||||
>
|
||||
<span className="sm:hidden">{userDisplayName.mobile}</span>
|
||||
<span className="hidden sm:inline md:hidden">{userDisplayName.tablet}</span>
|
||||
<span className="hidden md:inline">{userDisplayName.desktop}</span>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 sm:h-4 sm:w-4 transition-transform duration-200 ${
|
||||
showAccountDropdown ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showAccountDropdown && (
|
||||
<div
|
||||
className="absolute right-0 top-full w-48 bg-white dark:bg-gray-800 rounded-md shadow-2xl border border-gray-200 dark:border-gray-700 py-1 mt-1"
|
||||
style={{
|
||||
zIndex: 9999999,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to="/help"
|
||||
onClick={handleDropdownClose}
|
||||
className={`
|
||||
flex items-center px-4 py-2 text-sm transition-colors duration-200 cursor-pointer
|
||||
${
|
||||
isActivePath("/help")
|
||||
? getThemeClasses("pagination-active")
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5 mr-3 flex-shrink-0" />
|
||||
<span>Help</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/me"
|
||||
onClick={handleDropdownClose}
|
||||
className={`
|
||||
flex items-center px-4 py-2 text-sm transition-colors duration-200 cursor-pointer
|
||||
${
|
||||
isActivePath("/me")
|
||||
? getThemeClasses("pagination-active")
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<UserCircleIcon className="h-5 w-5 mr-3 flex-shrink-0" />
|
||||
<span>My Profile</span>
|
||||
</Link>
|
||||
|
||||
<hr className="my-1 border border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3 flex-shrink-0" />
|
||||
<span>Sign Off</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Logout Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showLogoutWarning}
|
||||
onClose={() => setShowLogoutWarning(false)}
|
||||
title="Are you sure?"
|
||||
footer={
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowLogoutWarning(false)}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
<Button variant="success" onClick={handleLogoutConfirm}>
|
||||
Yes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
You are about to log out of the system and you'll need to log in again
|
||||
next time. Are you sure you want to continue?
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopNavbar;
|
||||
302
web/maplefile-frontend/src/components/Navigation.jsx
Normal file
302
web/maplefile-frontend/src/components/Navigation.jsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// File: src/components/Navigation.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router";
|
||||
import { useAuth } from "../services/Services";
|
||||
import {
|
||||
LockClosedIcon,
|
||||
HomeIcon,
|
||||
FolderIcon,
|
||||
UserIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
const Navigation = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { authManager } = useAuth();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (authManager?.logout) {
|
||||
authManager.logout();
|
||||
}
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem("mapleopentech_access_token");
|
||||
localStorage.removeItem("mapleopentech_refresh_token");
|
||||
localStorage.removeItem("mapleopentech_user_email");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === "/file-manager") {
|
||||
return location.pathname.startsWith("/file-manager");
|
||||
}
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const mainNavItems = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
icon: HomeIcon,
|
||||
description: "Overview",
|
||||
},
|
||||
{
|
||||
name: "My Files",
|
||||
path: "/file-manager",
|
||||
icon: FolderIcon,
|
||||
description: "Your files",
|
||||
},
|
||||
];
|
||||
|
||||
const userEmail = authManager?.getCurrentUserEmail?.() || "User";
|
||||
const userInitial = userEmail.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={`sticky top-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? "bg-white/95 backdrop-blur-md shadow-lg border-b border-gray-200/50"
|
||||
: "bg-white border-b border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/dashboard" className="flex items-center space-x-3 group">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-600 to-red-800 rounded-xl opacity-0 group-hover:opacity-100 blur-xl transition-opacity duration-300"></div>
|
||||
<div className="relative flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-700 to-red-800 rounded-xl shadow-md group-hover:shadow-lg transform group-hover:scale-105 transition-all duration-200">
|
||||
<LockClosedIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-gray-900 group-hover:text-red-800 transition-colors duration-200">
|
||||
MapleFile
|
||||
</span>
|
||||
<span className="hidden sm:block text-xs text-gray-500">
|
||||
Secure Storage
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:flex items-center space-x-1">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`relative px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive(item.path)
|
||||
? "text-white"
|
||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{isActive(item.path) && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-700 to-red-800 rounded-lg"></div>
|
||||
)}
|
||||
<div className="relative flex items-center space-x-2">
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${
|
||||
isActive(item.path)
|
||||
? "text-white"
|
||||
: "text-gray-500 group-hover:text-gray-700"
|
||||
}`}
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{!isActive(item.path) && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-red-700 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
{/* Profile Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
||||
className="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<div className="h-8 w-8 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center text-white font-semibold text-sm shadow-sm">
|
||||
{userInitial}
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{userEmail.split("@")[0]}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Free Plan</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 transition-transform duration-200 ${
|
||||
isProfileMenuOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isProfileMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsProfileMenuOpen(false)}
|
||||
></div>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 z-20 animate-fade-in-down">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{userEmail}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Free Plan • 5GB Storage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/me"
|
||||
onClick={() => setIsProfileMenuOpen(false)}
|
||||
className="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200"
|
||||
>
|
||||
<UserIcon className="h-4 w-4 mr-3 text-gray-500" />
|
||||
My Profile
|
||||
</Link>
|
||||
|
||||
<div className="border-t border-gray-100 mt-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsProfileMenuOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="w-full flex items-center px-4 py-2.5 text-sm text-red-700 hover:bg-red-50 transition-colors duration-200"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-3 text-red-600" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="lg:hidden p-2 rounded-lg text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-all duration-200"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`lg:hidden fixed inset-0 z-40 transition-opacity duration-300 ${
|
||||
isMobileMenuOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-900/50 backdrop-blur-sm"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
></div>
|
||||
|
||||
<div
|
||||
className={`absolute right-0 top-0 h-full w-72 bg-white shadow-2xl transform transition-transform duration-300 ${
|
||||
isMobileMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Menu</h2>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-all duration-200"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile User Info */}
|
||||
<div className="flex items-center space-x-3 p-4 bg-gray-50 rounded-xl mb-6">
|
||||
<div className="h-10 w-10 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center text-white font-semibold">
|
||||
{userInitial}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{userEmail.split("@")[0]}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<div className="space-y-1">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 ${
|
||||
isActive(item.path)
|
||||
? "bg-gradient-to-r from-red-700 to-red-800 text-white"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Link
|
||||
to="/me"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-base font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all duration-200"
|
||||
>
|
||||
<UserIcon className="h-5 w-5" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="w-full flex items-center justify-center space-x-3 px-4 py-3 rounded-lg text-base font-medium text-white bg-gradient-to-r from-red-700 to-red-800 hover:from-red-800 hover:to-red-900 transition-all duration-200"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// File Path: src/components/UIX/ActionCard/ActionCard.jsx
|
||||
// Reusable ActionCard component for action grids - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme";
|
||||
|
||||
/**
|
||||
* Reusable ActionCard component - Performance Optimized
|
||||
* Theme-aware component using dark blue-grey slate color that complements both red and blue themes
|
||||
*
|
||||
* @param {string} title - Card title
|
||||
* @param {string} subtitle - Card subtitle/description
|
||||
* @param {React.Component} icon - Heroicon component
|
||||
* @param {string} path - Navigation path
|
||||
* @param {boolean} disabled - Whether the card is disabled
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const ActionCard = memo(
|
||||
function ActionCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
path,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
actionCard: getThemeClasses("action-card"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize all classes at once for better efficiency
|
||||
const classes = useMemo(() => {
|
||||
// Base classes array for cleaner composition
|
||||
const baseClasses = [
|
||||
"p-6",
|
||||
"rounded-lg",
|
||||
"text-center",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"min-h-[180px]",
|
||||
"flex",
|
||||
"flex-col",
|
||||
"justify-center",
|
||||
"items-center",
|
||||
];
|
||||
|
||||
// Build card classes based on disabled state
|
||||
let cardClasses;
|
||||
let iconClasses;
|
||||
|
||||
if (disabled) {
|
||||
baseClasses.push(
|
||||
"bg-gray-400",
|
||||
"cursor-not-allowed",
|
||||
"opacity-60",
|
||||
"text-gray-200",
|
||||
);
|
||||
iconClasses = "w-12 h-12 mb-3 mx-auto text-gray-200";
|
||||
} else {
|
||||
baseClasses.push(
|
||||
themeClasses.actionCard,
|
||||
"hover:shadow-lg",
|
||||
"hover:-translate-y-1",
|
||||
"cursor-pointer",
|
||||
);
|
||||
iconClasses = "w-12 h-12 mb-3 mx-auto text-white";
|
||||
}
|
||||
|
||||
// Add custom className if provided
|
||||
if (className) {
|
||||
baseClasses.push(className);
|
||||
}
|
||||
|
||||
cardClasses = baseClasses.join(" ");
|
||||
|
||||
return {
|
||||
card: cardClasses,
|
||||
icon: iconClasses,
|
||||
title: "text-lg font-bold mb-2 text-white",
|
||||
subtitle: "text-sm opacity-90 text-white",
|
||||
};
|
||||
}, [disabled, className, themeClasses.actionCard]);
|
||||
|
||||
// Memoize the card content separately from the Icon rendering
|
||||
const CardContent = useMemo(() => {
|
||||
return (
|
||||
<div className={classes.card}>
|
||||
<Icon className={classes.icon} />
|
||||
<h3 className={classes.title}>{title}</h3>
|
||||
<p className={classes.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}, [classes, Icon, title, subtitle]);
|
||||
|
||||
// Return content directly if disabled
|
||||
if (disabled) {
|
||||
return CardContent;
|
||||
}
|
||||
|
||||
// Return content wrapped in Link for enabled cards
|
||||
return (
|
||||
<Link to={path} className="block">
|
||||
{CardContent}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.subtitle === nextProps.subtitle &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.path === nextProps.path &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
ActionCard.displayName = "ActionCard";
|
||||
|
||||
export default ActionCard;
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// ===============================================
|
||||
// File Path: src/components/UIX/ActionCard/DeleteActionCard.jsx
|
||||
// DeleteActionCard component for destructive actions - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme";
|
||||
|
||||
/**
|
||||
* DeleteActionCard component - Performance Optimized
|
||||
* Theme-aware component that always uses red warning colors regardless of theme
|
||||
*
|
||||
* @param {string} title - Card title
|
||||
* @param {string} subtitle - Card subtitle/description
|
||||
* @param {React.Component} icon - Heroicon component
|
||||
* @param {string} path - Navigation path
|
||||
* @param {boolean} disabled - Whether the card is disabled
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const DeleteActionCard = memo(
|
||||
function DeleteActionCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
path,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
actionCardDelete: getThemeClasses("action-card-delete"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize all classes at once for better efficiency
|
||||
const classes = useMemo(() => {
|
||||
// Base classes array for cleaner composition
|
||||
const baseClasses = [
|
||||
"p-6",
|
||||
"rounded-lg",
|
||||
"text-center",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"min-h-[180px]",
|
||||
"flex",
|
||||
"flex-col",
|
||||
"justify-center",
|
||||
"items-center",
|
||||
];
|
||||
|
||||
// Build card classes based on disabled state
|
||||
let cardClasses;
|
||||
let iconClasses;
|
||||
|
||||
if (disabled) {
|
||||
baseClasses.push(
|
||||
"bg-gray-400",
|
||||
"cursor-not-allowed",
|
||||
"opacity-60",
|
||||
"text-gray-200",
|
||||
);
|
||||
iconClasses = "w-12 h-12 mb-3 mx-auto text-gray-200";
|
||||
} else {
|
||||
baseClasses.push(
|
||||
themeClasses.actionCardDelete,
|
||||
"hover:shadow-lg",
|
||||
"hover:-translate-y-1",
|
||||
"cursor-pointer",
|
||||
);
|
||||
iconClasses = "w-12 h-12 mb-3 mx-auto text-white";
|
||||
}
|
||||
|
||||
// Add custom className if provided
|
||||
if (className) {
|
||||
baseClasses.push(className);
|
||||
}
|
||||
|
||||
cardClasses = baseClasses.join(" ");
|
||||
|
||||
return {
|
||||
card: cardClasses,
|
||||
icon: iconClasses,
|
||||
title: "text-lg font-bold mb-2 text-white",
|
||||
subtitle: "text-sm opacity-90 text-white",
|
||||
};
|
||||
}, [disabled, className, themeClasses.actionCardDelete]);
|
||||
|
||||
// Memoize the card content separately from the Icon rendering
|
||||
const CardContent = useMemo(() => {
|
||||
return (
|
||||
<div className={classes.card}>
|
||||
<Icon className={classes.icon} />
|
||||
<h3 className={classes.title}>{title}</h3>
|
||||
<p className={classes.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}, [classes, Icon, title, subtitle]);
|
||||
|
||||
// Return content directly if disabled
|
||||
if (disabled) {
|
||||
return CardContent;
|
||||
}
|
||||
|
||||
// Return content wrapped in Link for enabled cards
|
||||
return (
|
||||
<Link to={path} className="block">
|
||||
{CardContent}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.subtitle === nextProps.subtitle &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.path === nextProps.path &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
DeleteActionCard.displayName = "DeleteActionCard";
|
||||
|
||||
export default DeleteActionCard;
|
||||
|
||||
// ===============================================
|
||||
// File Path: src/components/UIX/ActionCard/index.js
|
||||
// Export both action card variants
|
||||
|
||||
export { default as ActionCard } from "./ActionCard";
|
||||
export { default as DeleteActionCard } from "./DeleteActionCard";
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
// File Path: src/components/UIX/AddressDisplay/AddressDisplay.jsx
|
||||
// Reusable AddressDisplay component with theme-aware Google Maps link - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
import {
|
||||
MapPinIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
// Size configurations moved outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: {
|
||||
text: "text-xs sm:text-sm",
|
||||
icon: "w-3 sm:w-4 h-3 sm:h-4",
|
||||
mapIcon: "w-2 sm:w-3 h-2 sm:h-3",
|
||||
},
|
||||
md: {
|
||||
text: "text-sm sm:text-base lg:text-lg",
|
||||
icon: "w-4 sm:w-5 h-4 sm:h-5 lg:w-6 lg:h-6",
|
||||
mapIcon: "w-3 sm:w-4 h-3 sm:h-4",
|
||||
},
|
||||
lg: {
|
||||
text: "text-base sm:text-lg lg:text-xl",
|
||||
icon: "w-5 sm:w-6 h-5 sm:h-6 lg:w-7 lg:h-7",
|
||||
mapIcon: "w-4 sm:w-5 h-4 sm:h-5",
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function moved outside to prevent recreation
|
||||
const formatAddressHelper = (data) => {
|
||||
if (!data) return null;
|
||||
const parts = [];
|
||||
if (data.addressLine1) parts.push(data.addressLine1);
|
||||
if (data.addressLine2) parts.push(data.addressLine2);
|
||||
if (data.city) parts.push(data.city);
|
||||
if (data.region) parts.push(data.region);
|
||||
if (data.postalCode) parts.push(data.postalCode);
|
||||
if (data.country) parts.push(data.country);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
};
|
||||
|
||||
// Helper function for Google Maps URL
|
||||
const getGoogleMapsUrlHelper = (formattedAddress) => {
|
||||
if (!formattedAddress) return null;
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(formattedAddress)}`;
|
||||
};
|
||||
|
||||
// Static click handler - doesn't need to be recreated
|
||||
const handleMapsClick = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* AddressDisplay Component - Performance Optimized
|
||||
* Displays address information with theme-aware Google Maps link
|
||||
*
|
||||
* Features:
|
||||
* - Theme-aware Google Maps icon that adapts to blue/red themes
|
||||
* - Automatic address formatting from multiple fields
|
||||
* - Responsive sizing for mobile and desktop
|
||||
* - Optional Google Maps integration
|
||||
* - Fallback display for missing address info
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.addressData - Address object with fields
|
||||
* @param {string} props.addressData.addressLine1 - Primary address line
|
||||
* @param {string} props.addressData.addressLine2 - Secondary address line
|
||||
* @param {string} props.addressData.city - City
|
||||
* @param {string} props.addressData.region - State/Region
|
||||
* @param {string} props.addressData.postalCode - Postal/ZIP code
|
||||
* @param {string} props.addressData.country - Country
|
||||
* @param {string} props.className - Additional CSS classes
|
||||
* @param {string} props.size - Size variant: 'sm', 'md', 'lg'
|
||||
* @param {boolean} props.showIcon - Whether to show the map pin icon
|
||||
* @param {boolean} props.showMapsLink - Whether to show Google Maps link
|
||||
* @param {string} props.fallbackText - Text to show when no address provided
|
||||
*/
|
||||
const AddressDisplay = memo(
|
||||
function AddressDisplay({
|
||||
addressData,
|
||||
className = "",
|
||||
size = "md",
|
||||
showIcon = true,
|
||||
showMapsLink = true,
|
||||
fallbackText = "No address provided",
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Get size classes
|
||||
const sizes = useMemo(() => SIZE_CLASSES[size] || SIZE_CLASSES.md, [size]);
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
linkPrimary: getThemeClasses("link-primary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize formatted address with simpler dependency check
|
||||
const formattedAddress = useMemo(
|
||||
() => formatAddressHelper(addressData),
|
||||
// Use JSON.stringify for deep comparison of addressData object
|
||||
[addressData ? JSON.stringify(addressData) : null],
|
||||
);
|
||||
|
||||
// Memoize Google Maps URL
|
||||
const mapsUrl = useMemo(() => {
|
||||
if (!showMapsLink || !formattedAddress) return null;
|
||||
return getGoogleMapsUrlHelper(formattedAddress);
|
||||
}, [formattedAddress, showMapsLink]);
|
||||
|
||||
// Memoize all classes at once for better efficiency
|
||||
const classes = useMemo(() => {
|
||||
// Build container classes
|
||||
const containerClasses = [
|
||||
"flex",
|
||||
"items-start",
|
||||
sizes.text,
|
||||
"text-gray-600",
|
||||
"justify-center",
|
||||
"xl:justify-start",
|
||||
];
|
||||
|
||||
if (className) {
|
||||
containerClasses.push(className);
|
||||
}
|
||||
|
||||
// Build icon classes
|
||||
const iconClasses = [
|
||||
sizes.icon,
|
||||
"mr-2",
|
||||
"mt-0.5",
|
||||
"flex-shrink-0",
|
||||
"text-gray-400",
|
||||
];
|
||||
|
||||
// Build map icon classes
|
||||
const mapIconClasses = [sizes.mapIcon, themeClasses.linkPrimary];
|
||||
|
||||
// Build link classes
|
||||
const linkClasses = [
|
||||
"ml-2",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
themeClasses.linkPrimary,
|
||||
];
|
||||
|
||||
return {
|
||||
container: containerClasses.join(" "),
|
||||
icon: iconClasses.join(" "),
|
||||
mapIcon: mapIconClasses.join(" "),
|
||||
link: linkClasses.join(" "),
|
||||
fallbackText: "text-gray-500",
|
||||
addressText: "break-words",
|
||||
};
|
||||
}, [sizes, className, themeClasses.linkPrimary]);
|
||||
|
||||
// Render empty state
|
||||
if (!formattedAddress) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{showIcon && <MapPinIcon className={classes.icon} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={classes.fallbackText}>{fallbackText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render address with optional maps link
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{showIcon && <MapPinIcon className={classes.icon} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={classes.addressText}>{formattedAddress}</span>
|
||||
{showMapsLink && mapsUrl && (
|
||||
<a
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classes.link}
|
||||
onClick={handleMapsClick}
|
||||
aria-label="Open address in Google Maps"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className={classes.mapIcon} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
// Use JSON.stringify for addressData deep comparison
|
||||
return (
|
||||
JSON.stringify(prevProps.addressData) ===
|
||||
JSON.stringify(nextProps.addressData) &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.showIcon === nextProps.showIcon &&
|
||||
prevProps.showMapsLink === nextProps.showMapsLink &&
|
||||
prevProps.fallbackText === nextProps.fallbackText
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
AddressDisplay.displayName = "AddressDisplay";
|
||||
|
||||
export default AddressDisplay;
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
// File Path: web/frontend/src/components/UIX/AddressFormCard/AddressFormCard.jsx
|
||||
// Reusable AddressFormCard component for standard address forms - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { FormCard, FormSection, FormRow, Input, Select, Checkbox } from "../";
|
||||
|
||||
// Default options moved outside component to prevent recreation
|
||||
const DEFAULT_COUNTRY_OPTIONS = [
|
||||
{ value: "Canada", label: "Canada" },
|
||||
{ value: "United States", label: "United States" },
|
||||
{ value: "Mexico", label: "Mexico" },
|
||||
];
|
||||
|
||||
const DEFAULT_REGION_OPTIONS = [
|
||||
{ value: "Alberta", label: "Alberta" },
|
||||
{ value: "British Columbia", label: "British Columbia" },
|
||||
{ value: "Manitoba", label: "Manitoba" },
|
||||
{ value: "New Brunswick", label: "New Brunswick" },
|
||||
{ value: "Newfoundland and Labrador", label: "Newfoundland and Labrador" },
|
||||
{ value: "Northwest Territories", label: "Northwest Territories" },
|
||||
{ value: "Nova Scotia", label: "Nova Scotia" },
|
||||
{ value: "Nunavut", label: "Nunavut" },
|
||||
{ value: "Ontario", label: "Ontario" },
|
||||
{ value: "Prince Edward Island", label: "Prince Edward Island" },
|
||||
{ value: "Quebec", label: "Quebec" },
|
||||
{ value: "Saskatchewan", label: "Saskatchewan" },
|
||||
{ value: "Yukon", label: "Yukon" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Reusable AddressFormCard Component - Performance Optimized
|
||||
* A standardized address form card that handles billing/primary addresses
|
||||
* Perfect for any entity that needs address collection (Staff, Customer, Organization, etc.)
|
||||
*
|
||||
* Features:
|
||||
* - Complete address form with country, region, city, postal code, and address lines
|
||||
* - Configurable country and region options
|
||||
* - Optional shipping address toggle
|
||||
* - Proper form validation support
|
||||
* - Consistent styling with FormCard wrapper
|
||||
* - Optimized for minimal re-renders
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - Card title (e.g., "Billing Address", "Address Information")
|
||||
* @param {string} props.subtitle - Card subtitle
|
||||
* @param {React.Component} props.icon - Icon for the card header
|
||||
* @param {string} props.country - Country value
|
||||
* @param {function} props.onCountryChange - Country change handler (receives value)
|
||||
* @param {string} props.region - Region/Province value
|
||||
* @param {function} props.onRegionChange - Region change handler (receives value)
|
||||
* @param {string} props.city - City value
|
||||
* @param {function} props.onCityChange - City change handler (receives value)
|
||||
* @param {string} props.postalCode - Postal code value
|
||||
* @param {function} props.onPostalCodeChange - Postal code change handler (receives value)
|
||||
* @param {string} props.addressLine1 - Address line 1 value
|
||||
* @param {function} props.onAddressLine1Change - Address line 1 change handler (receives value)
|
||||
* @param {string} props.addressLine2 - Address line 2 value
|
||||
* @param {function} props.onAddressLine2Change - Address line 2 change handler (receives value)
|
||||
* @param {boolean} props.hasShippingAddress - Whether shipping address is enabled
|
||||
* @param {function} props.onHasShippingAddressChange - Shipping address toggle handler (receives checked)
|
||||
* @param {Array} props.countryOptions - Array of {value, label} country options
|
||||
* @param {Array} props.regionOptions - Array of {value, label} region options
|
||||
* @param {Object} props.errors - Error object with field names as keys
|
||||
* @param {boolean} props.showShippingToggle - Whether to show shipping address toggle
|
||||
* @param {string} props.maxWidth - Max width for the card
|
||||
* @param {string} props.className - Additional CSS classes
|
||||
*/
|
||||
const AddressFormCard = memo(
|
||||
function AddressFormCard({
|
||||
// Content props
|
||||
title = "Address Information",
|
||||
subtitle = "Enter address details",
|
||||
icon: Icon = HomeIcon,
|
||||
|
||||
// Address values
|
||||
country = "",
|
||||
region = "",
|
||||
city = "",
|
||||
postalCode = "",
|
||||
addressLine1 = "",
|
||||
addressLine2 = "",
|
||||
|
||||
// Change handlers
|
||||
onCountryChange = () => {},
|
||||
onRegionChange = () => {},
|
||||
onCityChange = () => {},
|
||||
onPostalCodeChange = () => {},
|
||||
onAddressLine1Change = () => {},
|
||||
onAddressLine2Change = () => {},
|
||||
|
||||
// Shipping address props
|
||||
hasShippingAddress = false,
|
||||
onHasShippingAddressChange = () => {},
|
||||
showShippingToggle = true,
|
||||
|
||||
// Options
|
||||
countryOptions = [],
|
||||
regionOptions = [],
|
||||
|
||||
// State props
|
||||
errors = {},
|
||||
|
||||
// Style props
|
||||
maxWidth = "7xl",
|
||||
className = "",
|
||||
}) {
|
||||
// Memoize final options to prevent recalculation
|
||||
const finalOptions = useMemo(
|
||||
() => ({
|
||||
country:
|
||||
countryOptions.length > 0 ? countryOptions : DEFAULT_COUNTRY_OPTIONS,
|
||||
region:
|
||||
regionOptions.length > 0 ? regionOptions : DEFAULT_REGION_OPTIONS,
|
||||
}),
|
||||
[countryOptions, regionOptions],
|
||||
);
|
||||
|
||||
// Memoize all error values at once for better efficiency
|
||||
const fieldErrors = useMemo(
|
||||
() => ({
|
||||
country: errors.country || null,
|
||||
region: errors.region || null,
|
||||
city: errors.city || null,
|
||||
postalCode: errors.postalCode || null,
|
||||
addressLine1: errors.addressLine1 || null,
|
||||
addressLine2: errors.addressLine2 || null,
|
||||
}),
|
||||
[
|
||||
errors.country,
|
||||
errors.region,
|
||||
errors.city,
|
||||
errors.postalCode,
|
||||
errors.addressLine1,
|
||||
errors.addressLine2,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize all field values at once
|
||||
const fieldValues = useMemo(
|
||||
() => ({
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
postalCode,
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
}),
|
||||
[country, region, city, postalCode, addressLine1, addressLine2],
|
||||
);
|
||||
|
||||
// Memoize all handlers at once
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
country: onCountryChange,
|
||||
region: onRegionChange,
|
||||
city: onCityChange,
|
||||
postalCode: onPostalCodeChange,
|
||||
addressLine1: onAddressLine1Change,
|
||||
addressLine2: onAddressLine2Change,
|
||||
hasShippingAddress: onHasShippingAddressChange,
|
||||
}),
|
||||
[
|
||||
onCountryChange,
|
||||
onRegionChange,
|
||||
onCityChange,
|
||||
onPostalCodeChange,
|
||||
onAddressLine1Change,
|
||||
onAddressLine2Change,
|
||||
onHasShippingAddressChange,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize the shipping toggle section
|
||||
const ShippingToggle = useMemo(() => {
|
||||
if (!showShippingToggle) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Checkbox
|
||||
label="Has shipping address different from billing address"
|
||||
checked={hasShippingAddress}
|
||||
onChange={handlers.hasShippingAddress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [showShippingToggle, hasShippingAddress, handlers.hasShippingAddress]);
|
||||
|
||||
// Memoize the entire form content
|
||||
const FormContent = useMemo(
|
||||
() => (
|
||||
<FormSection title="Location Details">
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
label="Country"
|
||||
value={fieldValues.country}
|
||||
onChange={handlers.country}
|
||||
options={finalOptions.country}
|
||||
error={fieldErrors.country}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Province/Territory"
|
||||
value={fieldValues.region}
|
||||
onChange={handlers.region}
|
||||
options={finalOptions.region}
|
||||
error={fieldErrors.region}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="City"
|
||||
type="text"
|
||||
value={fieldValues.city}
|
||||
onChange={handlers.city}
|
||||
placeholder="Enter city"
|
||||
error={fieldErrors.city}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Postal Code"
|
||||
type="text"
|
||||
value={fieldValues.postalCode}
|
||||
onChange={handlers.postalCode}
|
||||
placeholder="Enter postal code"
|
||||
error={fieldErrors.postalCode}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Address Line 1"
|
||||
type="text"
|
||||
value={fieldValues.addressLine1}
|
||||
onChange={handlers.addressLine1}
|
||||
placeholder="Enter street address"
|
||||
error={fieldErrors.addressLine1}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Address Line 2"
|
||||
type="text"
|
||||
value={fieldValues.addressLine2}
|
||||
onChange={handlers.addressLine2}
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
error={fieldErrors.addressLine2}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{ShippingToggle}
|
||||
</FormSection>
|
||||
),
|
||||
[fieldValues, handlers, finalOptions, fieldErrors, ShippingToggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={Icon}
|
||||
maxWidth={maxWidth}
|
||||
className={className}
|
||||
>
|
||||
{FormContent}
|
||||
</FormCard>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
// Header props
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.subtitle === nextProps.subtitle &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
// Field values
|
||||
prevProps.country === nextProps.country &&
|
||||
prevProps.region === nextProps.region &&
|
||||
prevProps.city === nextProps.city &&
|
||||
prevProps.postalCode === nextProps.postalCode &&
|
||||
prevProps.addressLine1 === nextProps.addressLine1 &&
|
||||
prevProps.addressLine2 === nextProps.addressLine2 &&
|
||||
// Handlers (check reference equality)
|
||||
prevProps.onCountryChange === nextProps.onCountryChange &&
|
||||
prevProps.onRegionChange === nextProps.onRegionChange &&
|
||||
prevProps.onCityChange === nextProps.onCityChange &&
|
||||
prevProps.onPostalCodeChange === nextProps.onPostalCodeChange &&
|
||||
prevProps.onAddressLine1Change === nextProps.onAddressLine1Change &&
|
||||
prevProps.onAddressLine2Change === nextProps.onAddressLine2Change &&
|
||||
prevProps.onHasShippingAddressChange ===
|
||||
nextProps.onHasShippingAddressChange &&
|
||||
// Shipping toggle
|
||||
prevProps.hasShippingAddress === nextProps.hasShippingAddress &&
|
||||
prevProps.showShippingToggle === nextProps.showShippingToggle &&
|
||||
// Options (deep comparison)
|
||||
JSON.stringify(prevProps.countryOptions) ===
|
||||
JSON.stringify(nextProps.countryOptions) &&
|
||||
JSON.stringify(prevProps.regionOptions) ===
|
||||
JSON.stringify(nextProps.regionOptions) &&
|
||||
// Errors (deep comparison)
|
||||
JSON.stringify(prevProps.errors) === JSON.stringify(nextProps.errors) &&
|
||||
// Style props
|
||||
prevProps.maxWidth === nextProps.maxWidth &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
AddressFormCard.displayName = "AddressFormCard";
|
||||
|
||||
export default AddressFormCard;
|
||||
|
||||
// Export for reuse in other components
|
||||
export { AddressFormCard };
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
// File Path: web/frontend/src/components/UIX/AddressFormStep/AddressFormStep.jsx
|
||||
// Reusable AddressFormStep component for complete address collection workflows - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { AddressFormCard, ShippingAddressFormCard } from "../";
|
||||
|
||||
/**
|
||||
* Reusable AddressFormStep Component - Performance Optimized
|
||||
* A complete address collection step that handles both billing and optional shipping addresses
|
||||
* Perfect for any entity that needs address collection (Staff, Customer, Organization, etc.)
|
||||
*
|
||||
* Features:
|
||||
* - Complete billing/primary address form
|
||||
* - Optional shipping/alternative address form
|
||||
* - Configurable country and region options
|
||||
* - Built-in form validation support
|
||||
* - Proper spacing and layout
|
||||
* - Optimized for minimal re-renders
|
||||
* - Can be used standalone or within wizard workflows
|
||||
*/
|
||||
const AddressFormStep = memo(
|
||||
function AddressFormStep({
|
||||
// Content props
|
||||
billingTitle,
|
||||
billingSubtitle = "Primary address information",
|
||||
shippingTitle = "Shipping Address",
|
||||
shippingSubtitle = "Alternative address for deliveries and shipments",
|
||||
billingIcon = HomeIcon,
|
||||
shippingIcon,
|
||||
|
||||
// Billing address values
|
||||
country = "",
|
||||
region = "",
|
||||
city = "",
|
||||
postalCode = "",
|
||||
addressLine1 = "",
|
||||
addressLine2 = "",
|
||||
|
||||
// Billing address handlers
|
||||
onCountryChange = () => {},
|
||||
onRegionChange = () => {},
|
||||
onCityChange = () => {},
|
||||
onPostalCodeChange = () => {},
|
||||
onAddressLine1Change = () => {},
|
||||
onAddressLine2Change = () => {},
|
||||
|
||||
// Shipping address toggle
|
||||
hasShippingAddress = false,
|
||||
onHasShippingAddressChange = () => {},
|
||||
|
||||
// Shipping address values
|
||||
shippingContactName = "",
|
||||
shippingPhone = "",
|
||||
shippingCountry = "",
|
||||
shippingRegion = "",
|
||||
shippingCity = "",
|
||||
shippingPostalCode = "",
|
||||
shippingAddressLine1 = "",
|
||||
shippingAddressLine2 = "",
|
||||
|
||||
// Shipping address handlers
|
||||
onShippingContactNameChange = () => {},
|
||||
onShippingPhoneChange = () => {},
|
||||
onShippingCountryChange = () => {},
|
||||
onShippingRegionChange = () => {},
|
||||
onShippingCityChange = () => {},
|
||||
onShippingPostalCodeChange = () => {},
|
||||
onShippingAddressLine1Change = () => {},
|
||||
onShippingAddressLine2Change = () => {},
|
||||
|
||||
// Options
|
||||
countryOptions = [],
|
||||
regionOptions = [],
|
||||
|
||||
// State props
|
||||
errors = {},
|
||||
showShippingToggle = true,
|
||||
|
||||
// Style props
|
||||
className = "",
|
||||
}) {
|
||||
// Determine billing title based on whether shipping is enabled
|
||||
const finalBillingTitle = useMemo(
|
||||
() =>
|
||||
billingTitle ||
|
||||
(hasShippingAddress ? "Billing Address" : "Address Information"),
|
||||
[billingTitle, hasShippingAddress],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() => `space-y-8 ${className}`.trim(),
|
||||
[className],
|
||||
);
|
||||
|
||||
// Group billing address data for cleaner memoization
|
||||
const billingData = useMemo(
|
||||
() => ({
|
||||
values: {
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
postalCode,
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
},
|
||||
handlers: {
|
||||
onCountryChange,
|
||||
onRegionChange,
|
||||
onCityChange,
|
||||
onPostalCodeChange,
|
||||
onAddressLine1Change,
|
||||
onAddressLine2Change,
|
||||
},
|
||||
meta: {
|
||||
title: finalBillingTitle,
|
||||
subtitle: billingSubtitle,
|
||||
icon: billingIcon,
|
||||
hasShippingAddress,
|
||||
onHasShippingAddressChange,
|
||||
showShippingToggle,
|
||||
},
|
||||
}),
|
||||
[
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
postalCode,
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
onCountryChange,
|
||||
onRegionChange,
|
||||
onCityChange,
|
||||
onPostalCodeChange,
|
||||
onAddressLine1Change,
|
||||
onAddressLine2Change,
|
||||
finalBillingTitle,
|
||||
billingSubtitle,
|
||||
billingIcon,
|
||||
hasShippingAddress,
|
||||
onHasShippingAddressChange,
|
||||
showShippingToggle,
|
||||
],
|
||||
);
|
||||
|
||||
// Group shipping address data for cleaner memoization
|
||||
const shippingData = useMemo(
|
||||
() => ({
|
||||
values: {
|
||||
contactName: shippingContactName,
|
||||
phone: shippingPhone,
|
||||
country: shippingCountry,
|
||||
region: shippingRegion,
|
||||
city: shippingCity,
|
||||
postalCode: shippingPostalCode,
|
||||
addressLine1: shippingAddressLine1,
|
||||
addressLine2: shippingAddressLine2,
|
||||
},
|
||||
handlers: {
|
||||
onContactNameChange: onShippingContactNameChange,
|
||||
onPhoneChange: onShippingPhoneChange,
|
||||
onCountryChange: onShippingCountryChange,
|
||||
onRegionChange: onShippingRegionChange,
|
||||
onCityChange: onShippingCityChange,
|
||||
onPostalCodeChange: onShippingPostalCodeChange,
|
||||
onAddressLine1Change: onShippingAddressLine1Change,
|
||||
onAddressLine2Change: onShippingAddressLine2Change,
|
||||
},
|
||||
meta: {
|
||||
title: shippingTitle,
|
||||
subtitle: shippingSubtitle,
|
||||
icon: shippingIcon,
|
||||
},
|
||||
}),
|
||||
[
|
||||
shippingContactName,
|
||||
shippingPhone,
|
||||
shippingCountry,
|
||||
shippingRegion,
|
||||
shippingCity,
|
||||
shippingPostalCode,
|
||||
shippingAddressLine1,
|
||||
shippingAddressLine2,
|
||||
onShippingContactNameChange,
|
||||
onShippingPhoneChange,
|
||||
onShippingCountryChange,
|
||||
onShippingRegionChange,
|
||||
onShippingCityChange,
|
||||
onShippingPostalCodeChange,
|
||||
onShippingAddressLine1Change,
|
||||
onShippingAddressLine2Change,
|
||||
shippingTitle,
|
||||
shippingSubtitle,
|
||||
shippingIcon,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize the billing address card
|
||||
const BillingAddressCard = useMemo(
|
||||
() => (
|
||||
<AddressFormCard
|
||||
title={billingData.meta.title}
|
||||
subtitle={billingData.meta.subtitle}
|
||||
icon={billingData.meta.icon}
|
||||
country={billingData.values.country}
|
||||
onCountryChange={billingData.handlers.onCountryChange}
|
||||
region={billingData.values.region}
|
||||
onRegionChange={billingData.handlers.onRegionChange}
|
||||
city={billingData.values.city}
|
||||
onCityChange={billingData.handlers.onCityChange}
|
||||
postalCode={billingData.values.postalCode}
|
||||
onPostalCodeChange={billingData.handlers.onPostalCodeChange}
|
||||
addressLine1={billingData.values.addressLine1}
|
||||
onAddressLine1Change={billingData.handlers.onAddressLine1Change}
|
||||
addressLine2={billingData.values.addressLine2}
|
||||
onAddressLine2Change={billingData.handlers.onAddressLine2Change}
|
||||
hasShippingAddress={billingData.meta.hasShippingAddress}
|
||||
onHasShippingAddressChange={
|
||||
billingData.meta.onHasShippingAddressChange
|
||||
}
|
||||
countryOptions={countryOptions}
|
||||
regionOptions={regionOptions}
|
||||
errors={errors}
|
||||
showShippingToggle={billingData.meta.showShippingToggle}
|
||||
/>
|
||||
),
|
||||
[billingData, countryOptions, regionOptions, errors],
|
||||
);
|
||||
|
||||
// Memoize the shipping address card
|
||||
const ShippingCard = useMemo(() => {
|
||||
if (!hasShippingAddress) return null;
|
||||
|
||||
return (
|
||||
<ShippingAddressFormCard
|
||||
title={shippingData.meta.title}
|
||||
subtitle={shippingData.meta.subtitle}
|
||||
icon={shippingData.meta.icon}
|
||||
contactName={shippingData.values.contactName}
|
||||
onContactNameChange={shippingData.handlers.onContactNameChange}
|
||||
phone={shippingData.values.phone}
|
||||
onPhoneChange={shippingData.handlers.onPhoneChange}
|
||||
country={shippingData.values.country}
|
||||
onCountryChange={shippingData.handlers.onCountryChange}
|
||||
region={shippingData.values.region}
|
||||
onRegionChange={shippingData.handlers.onRegionChange}
|
||||
city={shippingData.values.city}
|
||||
onCityChange={shippingData.handlers.onCityChange}
|
||||
postalCode={shippingData.values.postalCode}
|
||||
onPostalCodeChange={shippingData.handlers.onPostalCodeChange}
|
||||
addressLine1={shippingData.values.addressLine1}
|
||||
onAddressLine1Change={shippingData.handlers.onAddressLine1Change}
|
||||
addressLine2={shippingData.values.addressLine2}
|
||||
onAddressLine2Change={shippingData.handlers.onAddressLine2Change}
|
||||
countryOptions={countryOptions}
|
||||
regionOptions={regionOptions}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
hasShippingAddress,
|
||||
shippingData,
|
||||
countryOptions,
|
||||
regionOptions,
|
||||
errors,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Billing/Primary Address Section */}
|
||||
{BillingAddressCard}
|
||||
|
||||
{/* Shipping Address Section */}
|
||||
{ShippingCard}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
// Content props
|
||||
prevProps.billingTitle === nextProps.billingTitle &&
|
||||
prevProps.billingSubtitle === nextProps.billingSubtitle &&
|
||||
prevProps.shippingTitle === nextProps.shippingTitle &&
|
||||
prevProps.shippingSubtitle === nextProps.shippingSubtitle &&
|
||||
prevProps.billingIcon === nextProps.billingIcon &&
|
||||
prevProps.shippingIcon === nextProps.shippingIcon &&
|
||||
// Billing address values
|
||||
prevProps.country === nextProps.country &&
|
||||
prevProps.region === nextProps.region &&
|
||||
prevProps.city === nextProps.city &&
|
||||
prevProps.postalCode === nextProps.postalCode &&
|
||||
prevProps.addressLine1 === nextProps.addressLine1 &&
|
||||
prevProps.addressLine2 === nextProps.addressLine2 &&
|
||||
// Billing handlers
|
||||
prevProps.onCountryChange === nextProps.onCountryChange &&
|
||||
prevProps.onRegionChange === nextProps.onRegionChange &&
|
||||
prevProps.onCityChange === nextProps.onCityChange &&
|
||||
prevProps.onPostalCodeChange === nextProps.onPostalCodeChange &&
|
||||
prevProps.onAddressLine1Change === nextProps.onAddressLine1Change &&
|
||||
prevProps.onAddressLine2Change === nextProps.onAddressLine2Change &&
|
||||
// Shipping toggle
|
||||
prevProps.hasShippingAddress === nextProps.hasShippingAddress &&
|
||||
prevProps.onHasShippingAddressChange ===
|
||||
nextProps.onHasShippingAddressChange &&
|
||||
prevProps.showShippingToggle === nextProps.showShippingToggle &&
|
||||
// Shipping address values
|
||||
prevProps.shippingContactName === nextProps.shippingContactName &&
|
||||
prevProps.shippingPhone === nextProps.shippingPhone &&
|
||||
prevProps.shippingCountry === nextProps.shippingCountry &&
|
||||
prevProps.shippingRegion === nextProps.shippingRegion &&
|
||||
prevProps.shippingCity === nextProps.shippingCity &&
|
||||
prevProps.shippingPostalCode === nextProps.shippingPostalCode &&
|
||||
prevProps.shippingAddressLine1 === nextProps.shippingAddressLine1 &&
|
||||
prevProps.shippingAddressLine2 === nextProps.shippingAddressLine2 &&
|
||||
// Shipping handlers
|
||||
prevProps.onShippingContactNameChange ===
|
||||
nextProps.onShippingContactNameChange &&
|
||||
prevProps.onShippingPhoneChange === nextProps.onShippingPhoneChange &&
|
||||
prevProps.onShippingCountryChange === nextProps.onShippingCountryChange &&
|
||||
prevProps.onShippingRegionChange === nextProps.onShippingRegionChange &&
|
||||
prevProps.onShippingCityChange === nextProps.onShippingCityChange &&
|
||||
prevProps.onShippingPostalCodeChange ===
|
||||
nextProps.onShippingPostalCodeChange &&
|
||||
prevProps.onShippingAddressLine1Change ===
|
||||
nextProps.onShippingAddressLine1Change &&
|
||||
prevProps.onShippingAddressLine2Change ===
|
||||
nextProps.onShippingAddressLine2Change &&
|
||||
// Options and errors (deep comparison)
|
||||
JSON.stringify(prevProps.countryOptions) ===
|
||||
JSON.stringify(nextProps.countryOptions) &&
|
||||
JSON.stringify(prevProps.regionOptions) ===
|
||||
JSON.stringify(nextProps.regionOptions) &&
|
||||
JSON.stringify(prevProps.errors) === JSON.stringify(nextProps.errors) &&
|
||||
// Style props
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
AddressFormStep.displayName = "AddressFormStep";
|
||||
|
||||
export default AddressFormStep;
|
||||
|
||||
// Export for reuse in other components
|
||||
export { AddressFormStep };
|
||||
261
web/maplefile-frontend/src/components/UIX/Alert/Alert.jsx
Normal file
261
web/maplefile-frontend/src/components/UIX/Alert/Alert.jsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// File: src/components/UIX/Alert/Alert.jsx
|
||||
// Alert Component - Performance Optimized
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Animation styles moved outside and injected once
|
||||
const ANIMATION_STYLES = `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
`;
|
||||
|
||||
// Inject styles once when module loads
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.querySelector("#alert-styles")
|
||||
) {
|
||||
const styleSheet = document.createElement("style");
|
||||
styleSheet.id = "alert-styles";
|
||||
styleSheet.textContent = ANIMATION_STYLES;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Icon components map moved outside to prevent recreation
|
||||
const ICON_COMPONENTS = {
|
||||
warning: ExclamationTriangleIcon,
|
||||
error: XCircleIcon,
|
||||
success: CheckCircleIcon,
|
||||
info: InformationCircleIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* Alert Component - Performance Optimized
|
||||
* Displays contextual feedback messages for user actions
|
||||
*
|
||||
* @param {string} type - Type of alert: 'info', 'success', 'warning', 'error'
|
||||
* @param {React.ReactNode} children - Alert content
|
||||
* @param {string} message - Alternative to children, text message to display
|
||||
* @param {boolean} dismissible - Whether the alert can be dismissed
|
||||
* @param {function} onDismiss - Callback when alert is dismissed
|
||||
* @param {function} onClose - Alternative to onDismiss for backward compatibility
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} enhanced - Use enhanced styling with border-left accent
|
||||
* @param {React.Component} icon - Custom icon component (optional)
|
||||
*/
|
||||
const Alert = memo(
|
||||
function Alert({
|
||||
type = "info",
|
||||
children,
|
||||
message,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
onClose,
|
||||
className = "",
|
||||
enhanced = false,
|
||||
icon,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize all theme classes at once
|
||||
const themeClasses = useMemo(() => {
|
||||
const alertTypes = ["warning", "error", "success", "info"];
|
||||
const classes = {};
|
||||
|
||||
alertTypes.forEach((alertType) => {
|
||||
classes[`${alertType}Bg`] = getThemeClasses(`alert-${alertType}-bg`);
|
||||
classes[`${alertType}Border`] = getThemeClasses(
|
||||
`alert-${alertType}-border`,
|
||||
);
|
||||
classes[`${alertType}Text`] = getThemeClasses(
|
||||
`alert-${alertType}-text`,
|
||||
);
|
||||
if (enhanced) {
|
||||
classes[`${alertType}Hover`] = getThemeClasses(
|
||||
`alert-${alertType}-hover`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return classes;
|
||||
}, [getThemeClasses, enhanced]);
|
||||
|
||||
// Memoize all styles at once
|
||||
const styles = useMemo(() => {
|
||||
// Base styles
|
||||
const base = enhanced
|
||||
? "p-4 sm:p-5 rounded-xl shadow-sm animate-slideIn border-l-4"
|
||||
: "p-4 rounded-lg border animate-fade-in";
|
||||
|
||||
// Alert type styles
|
||||
const typeStyles = {
|
||||
warning: enhanced
|
||||
? `${themeClasses.warningBg} ${themeClasses.warningBorder} ${themeClasses.warningText}`
|
||||
: `${themeClasses.warningBg} ${themeClasses.warningText} ${themeClasses.warningBorder}`,
|
||||
error: enhanced
|
||||
? `${themeClasses.errorBg} ${themeClasses.errorBorder} ${themeClasses.errorText}`
|
||||
: `${themeClasses.errorBg} ${themeClasses.errorText} ${themeClasses.errorBorder}`,
|
||||
success: enhanced
|
||||
? `${themeClasses.successBg} ${themeClasses.successBorder} ${themeClasses.successText}`
|
||||
: `${themeClasses.successBg} ${themeClasses.successText} ${themeClasses.successBorder}`,
|
||||
info: enhanced
|
||||
? `${themeClasses.infoBg} ${themeClasses.infoBorder} ${themeClasses.infoText} border-l-4`
|
||||
: `${themeClasses.infoBg} ${themeClasses.infoText} ${themeClasses.infoBorder}`,
|
||||
};
|
||||
|
||||
const alertTypeStyle = typeStyles[type] || typeStyles.info;
|
||||
|
||||
// Icon styles
|
||||
const icon = enhanced
|
||||
? "h-5 w-5 sm:h-6 sm:w-6 flex-shrink-0"
|
||||
: "h-5 w-5 flex-shrink-0";
|
||||
|
||||
// Button styles
|
||||
let button;
|
||||
if (!enhanced) {
|
||||
button = "flex-shrink-0 ml-auto hover:opacity-70 transition-opacity";
|
||||
} else {
|
||||
const typeColorMap = {
|
||||
info: themeClasses.infoHover || "hover:bg-blue-100",
|
||||
warning: themeClasses.warningHover || "hover:bg-amber-100",
|
||||
error: themeClasses.errorHover || "hover:bg-red-100",
|
||||
success: themeClasses.successHover || "hover:bg-green-100",
|
||||
};
|
||||
|
||||
button = `inline-flex transition-colors duration-200 p-2 rounded-lg ${typeColorMap[type] || typeColorMap.info}`;
|
||||
}
|
||||
|
||||
// Container classes
|
||||
const marginClass = enhanced ? "mb-6 sm:mb-8" : "mb-5";
|
||||
const container =
|
||||
`${base} ${alertTypeStyle} ${className} ${marginClass}`.trim();
|
||||
|
||||
// Content classes
|
||||
const content = enhanced
|
||||
? "text-sm sm:text-base font-medium text-current"
|
||||
: "text-sm font-medium text-current";
|
||||
|
||||
return {
|
||||
container,
|
||||
icon,
|
||||
button,
|
||||
content,
|
||||
};
|
||||
}, [type, enhanced, className, themeClasses]);
|
||||
|
||||
// Determine content to display
|
||||
const content = children || message;
|
||||
|
||||
// Determine if alert should be dismissible
|
||||
const isDismissible = dismissible || !!onClose || !!onDismiss;
|
||||
|
||||
// Memoize dismiss handler
|
||||
const handleDismiss = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
} else if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
[onClose, onDismiss],
|
||||
);
|
||||
|
||||
// Get the appropriate icon component
|
||||
const IconComponent = icon || ICON_COMPONENTS[type] || ICON_COMPONENTS.info;
|
||||
|
||||
// Memoize dismiss button
|
||||
const DismissButton = useMemo(() => {
|
||||
if (!isDismissible) return null;
|
||||
|
||||
return (
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={styles.button}
|
||||
aria-label="Dismiss alert"
|
||||
type="button"
|
||||
>
|
||||
<XMarkIcon className={styles.icon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}, [isDismissible, handleDismiss, styles.button, styles.icon]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={styles.icon} />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className={styles.content}>{content}</div>
|
||||
</div>
|
||||
{DismissButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.message === nextProps.message &&
|
||||
prevProps.dismissible === nextProps.dismissible &&
|
||||
prevProps.onDismiss === nextProps.onDismiss &&
|
||||
prevProps.onClose === nextProps.onClose &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.enhanced === nextProps.enhanced &&
|
||||
prevProps.icon === nextProps.icon
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
// Export aliases for backward compatibility - also memoized
|
||||
export const Notification = Alert;
|
||||
export const Message = Alert;
|
||||
|
||||
export default Alert;
|
||||
|
|
@ -0,0 +1,855 @@
|
|||
// File Path: src/components/UIX/AttachmentsView/AttachmentsView.jsx
|
||||
// Reusable AttachmentsView component for entity attachment management - Performance Optimized
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
ChartBarIcon,
|
||||
UserGroupIcon,
|
||||
InformationCircleIcon,
|
||||
PaperClipIcon,
|
||||
ChevronLeftIcon,
|
||||
ArrowPathIcon,
|
||||
PlusCircleIcon,
|
||||
ClockIcon,
|
||||
ArchiveBoxIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
UserIcon,
|
||||
BriefcaseIcon,
|
||||
DocumentIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
UIXThemeProvider,
|
||||
useUIXTheme,
|
||||
Breadcrumb,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Alert,
|
||||
ContactLink,
|
||||
AddressDisplay,
|
||||
Tabs,
|
||||
} from "../";
|
||||
import { formatDateForDisplay } from "../../../services/Helpers/DateFormatter";
|
||||
|
||||
// Constants
|
||||
const ACTIVE_STATUS = 1;
|
||||
const ARCHIVED_STATUS = 2;
|
||||
|
||||
// Helper function to format file size - pure function at module level
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* Memoized Attachment Row Component - Prevents re-rendering of all rows when one changes
|
||||
*/
|
||||
const AttachmentRow = memo(
|
||||
({
|
||||
attachment,
|
||||
getThemeClasses,
|
||||
onRowClick,
|
||||
onViewClick,
|
||||
onDeleteClick,
|
||||
viewPath,
|
||||
showDeleteButton,
|
||||
}) => {
|
||||
// Memoize computed values
|
||||
const fileExtension = useMemo(
|
||||
() =>
|
||||
attachment.filename
|
||||
? attachment.filename.split(".").pop().toUpperCase()
|
||||
: "UNKNOWN",
|
||||
[attachment.filename],
|
||||
);
|
||||
|
||||
const fileSize = useMemo(
|
||||
() =>
|
||||
attachment.fileSizeBytes
|
||||
? formatFileSize(attachment.fileSizeBytes)
|
||||
: "Unknown",
|
||||
[attachment.fileSizeBytes],
|
||||
);
|
||||
|
||||
const createdDate = useMemo(
|
||||
() =>
|
||||
attachment.createdAt
|
||||
? formatDateForDisplay(attachment.createdAt)
|
||||
: "Unknown",
|
||||
[attachment.createdAt],
|
||||
);
|
||||
|
||||
// Memoize row click handler
|
||||
const handleRowClick = useCallback(() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(attachment);
|
||||
}
|
||||
}, [onRowClick, attachment]);
|
||||
|
||||
// Memoize view click handler
|
||||
const handleViewClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (onViewClick) {
|
||||
onViewClick(e, attachment.id);
|
||||
}
|
||||
},
|
||||
[onViewClick, attachment.id],
|
||||
);
|
||||
|
||||
// Memoize delete click handler
|
||||
const handleDeleteClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (onDeleteClick) {
|
||||
onDeleteClick(e, attachment);
|
||||
}
|
||||
},
|
||||
[onDeleteClick, attachment],
|
||||
);
|
||||
|
||||
// Memoize class strings
|
||||
const rowClasses = useMemo(
|
||||
() =>
|
||||
`border-b ${getThemeClasses("card-border")} hover:${getThemeClasses("bg-hover")} cursor-pointer transition-colors`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const iconClasses = useMemo(
|
||||
() => `w-5 h-5 mr-2 ${getThemeClasses("text-muted")}`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const descriptionClasses = useMemo(
|
||||
() => `text-sm ${getThemeClasses("text-secondary")}`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const deleteButtonClasses = useMemo(
|
||||
() =>
|
||||
`${getThemeClasses("text-danger")} ${getThemeClasses("hover:text-danger-dark")} ${getThemeClasses("border-danger")} ${getThemeClasses("hover:border-danger-dark")}`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={rowClasses} onClick={handleRowClick}>
|
||||
<td className={`py-3 px-4 ${getThemeClasses("text-primary")}`}>
|
||||
<div className="flex items-center">
|
||||
<DocumentIcon className={iconClasses} />
|
||||
<div>
|
||||
<div className="font-medium">{attachment.title}</div>
|
||||
<div className={descriptionClasses}>{attachment.filename}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
|
||||
<div className="max-w-xs truncate">
|
||||
{attachment.description || "No description"}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{fileExtension}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
|
||||
{fileSize}
|
||||
</td>
|
||||
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
|
||||
{createdDate}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{viewPath && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleViewClick}
|
||||
icon={EyeIcon}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
icon={TrashIcon}
|
||||
className={deleteButtonClasses}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.attachment.id === nextProps.attachment.id &&
|
||||
prevProps.attachment.title === nextProps.attachment.title &&
|
||||
prevProps.attachment.filename === nextProps.attachment.filename &&
|
||||
prevProps.attachment.description === nextProps.attachment.description &&
|
||||
prevProps.attachment.fileSizeBytes ===
|
||||
nextProps.attachment.fileSizeBytes &&
|
||||
prevProps.attachment.createdAt === nextProps.attachment.createdAt &&
|
||||
prevProps.viewPath === nextProps.viewPath &&
|
||||
prevProps.showDeleteButton === nextProps.showDeleteButton
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AttachmentRow.displayName = "AttachmentRow";
|
||||
|
||||
/**
|
||||
* Reusable AttachmentsView Component - Performance Optimized
|
||||
* A complete attachments management view that provides consistent layout and functionality
|
||||
* for any entity that supports attachments (staff, customers, events, etc.)
|
||||
*/
|
||||
|
||||
// Inner component that uses the theme hook - optimized for performance
|
||||
const AttachmentsViewInner = memo(
|
||||
function AttachmentsViewInner({
|
||||
entityData,
|
||||
entityId,
|
||||
entityType,
|
||||
breadcrumbItems,
|
||||
headerConfig,
|
||||
fieldSections,
|
||||
actionButtons,
|
||||
tabs,
|
||||
alerts,
|
||||
attachments,
|
||||
onAttachmentClick,
|
||||
onDeleteAttachment,
|
||||
onRefreshEntity,
|
||||
onUnauthorized,
|
||||
isLoading,
|
||||
error,
|
||||
onErrorClose,
|
||||
canAdd,
|
||||
addPath,
|
||||
viewPath,
|
||||
editPath,
|
||||
deletePath,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
previousCursors,
|
||||
nextCursor,
|
||||
onNextClick,
|
||||
onPreviousClick,
|
||||
className,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Use refs to track mounted state and abort controllers
|
||||
const isMountedRef = useRef(true);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Local state for refresh functionality
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize theme classes for performance
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
borderPrimary: getThemeClasses("border-primary"),
|
||||
textSecondary: getThemeClasses("text-secondary"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
bgHover: getThemeClasses("bg-hover"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
textDanger: getThemeClasses("text-danger"),
|
||||
hoverTextDangerDark: getThemeClasses("hover:text-danger-dark"),
|
||||
borderDanger: getThemeClasses("border-danger"),
|
||||
hoverBorderDangerDark: getThemeClasses("hover:border-danger-dark"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Handle refresh with proper cleanup
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!onRefreshEntity || isRefreshing || !isMountedRef.current) return;
|
||||
|
||||
// Cancel any previous refresh
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefreshEntity(entityId, onUnauthorized);
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
console.log("Refresh cancelled");
|
||||
return;
|
||||
}
|
||||
console.error("Refresh failed:", error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsRefreshing(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [onRefreshEntity, entityId, onUnauthorized, isRefreshing]);
|
||||
|
||||
// Create status badge component with memoization
|
||||
const statusBadge = useMemo(() => {
|
||||
if (!entityData) return null;
|
||||
|
||||
if (entityData.isBanned) {
|
||||
return (
|
||||
<Badge variant="error" size="sm">
|
||||
<XCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
Banned
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (entityData.status === ACTIVE_STATUS) {
|
||||
return (
|
||||
<Badge variant="primary" size="sm">
|
||||
<CheckCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
<ArchiveBoxIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
Archived
|
||||
</Badge>
|
||||
);
|
||||
}, [entityData]);
|
||||
|
||||
// Memoize filtered field sections
|
||||
const { avatarSection, primaryFieldSections, secondaryFieldSections } =
|
||||
useMemo(
|
||||
() => ({
|
||||
avatarSection: fieldSections?.find(
|
||||
(section) => section.type === "avatar",
|
||||
),
|
||||
primaryFieldSections:
|
||||
fieldSections?.filter((section) => section.column === "primary") ||
|
||||
[],
|
||||
secondaryFieldSections:
|
||||
fieldSections?.filter(
|
||||
(section) => section.column === "secondary",
|
||||
) || [],
|
||||
}),
|
||||
[fieldSections],
|
||||
);
|
||||
|
||||
// Memoize attachment data
|
||||
const { attachmentResults, attachmentCount, hasNextPage } = useMemo(
|
||||
() => ({
|
||||
attachmentResults: attachments?.results || [],
|
||||
attachmentCount:
|
||||
attachments?.count || attachments?.results?.length || 0,
|
||||
hasNextPage: attachments?.hasNextPage || false,
|
||||
}),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
// Handler callbacks
|
||||
const handleAddAttachment = useCallback(() => {
|
||||
if (addPath) {
|
||||
navigate(addPath);
|
||||
}
|
||||
}, [addPath, navigate]);
|
||||
|
||||
const handleViewAttachment = useCallback(
|
||||
(e, attachmentId) => {
|
||||
if (viewPath) {
|
||||
navigate(viewPath.replace("{id}", attachmentId));
|
||||
}
|
||||
},
|
||||
[viewPath, navigate],
|
||||
);
|
||||
|
||||
const handleDeleteAttachment = useCallback(
|
||||
(e, attachment) => {
|
||||
if (onDeleteAttachment) {
|
||||
onDeleteAttachment(attachment);
|
||||
}
|
||||
},
|
||||
[onDeleteAttachment],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(attachment) => {
|
||||
if (onAttachmentClick) {
|
||||
onAttachmentClick(attachment);
|
||||
}
|
||||
},
|
||||
[onAttachmentClick],
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !entityData?.id) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`}
|
||||
></div>
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base ${themeClasses.textSecondary}`}
|
||||
>
|
||||
{headerConfig?.loadingText || `Loading ${entityType}...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 ${className}`}
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbItems && breadcrumbItems.length > 0 && (
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
)}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{alerts?.archived &&
|
||||
entityData &&
|
||||
entityData.status === ARCHIVED_STATUS && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={alerts.archived.message || "This item is archived"}
|
||||
icon={alerts.archived.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{alerts?.banned && entityData && entityData.isBanned && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={alerts.banned.message || "This item is banned"}
|
||||
icon={alerts.banned.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
onClose={onErrorClose}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content with Header */}
|
||||
<div className="shadow-sm">
|
||||
{entityData ? (
|
||||
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
|
||||
{/* Header with Actions */}
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
|
||||
{headerConfig?.icon && (
|
||||
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
|
||||
)}
|
||||
{headerConfig?.title || `${entityType} - Attachments`}
|
||||
</h2>
|
||||
{actionButtons && actionButtons.length > 0 && (
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{actionButtons.map((button, index) =>
|
||||
button.component ? (
|
||||
<div key={index}>{button.component}</div>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
variant={button.variant}
|
||||
onClick={button.onClick}
|
||||
disabled={button.disabled}
|
||||
icon={button.icon}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation and Content */}
|
||||
<div
|
||||
className={`${themeClasses.bgCard} border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
|
||||
>
|
||||
{tabs && tabs.length > 0 && <Tabs tabs={tabs} mode="routing" />}
|
||||
|
||||
{/* Entity Summary Layout */}
|
||||
<div className="py-4 sm:py-6 md:py-8 lg:py-10 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col xl:flex-row gap-4 sm:gap-6 lg:gap-8 xl:gap-12 items-center xl:items-start justify-center max-w-6xl mx-auto">
|
||||
{/* Avatar Section */}
|
||||
{avatarSection && (
|
||||
<div className="flex-shrink-0 order-1 xl:order-1">
|
||||
{avatarSection.component}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Content Section */}
|
||||
<div className="flex-grow order-2 xl:order-2 text-center xl:text-left space-y-3 sm:space-y-4 lg:space-y-5">
|
||||
{primaryFieldSections.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Secondary Information Section */}
|
||||
<div className="flex-shrink-0 order-3 xl:order-3 xl:text-right">
|
||||
{secondaryFieldSections.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments Content */}
|
||||
<div className="px-4 sm:px-6 lg:px-8 pb-6 sm:pb-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg sm:text-xl font-semibold ${themeClasses.textPrimary} mb-1`}
|
||||
>
|
||||
Attachments
|
||||
</h2>
|
||||
<p className={`text-sm ${themeClasses.textSecondary}`}>
|
||||
Manage attachments for this {entityType}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
icon={ArrowPathIcon}
|
||||
disabled={isRefreshing}
|
||||
size="sm"
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
{canAdd && addPath && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddAttachment}
|
||||
icon={PlusCircleIcon}
|
||||
size="sm"
|
||||
>
|
||||
Add Attachment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments List */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-8 w-8 border-b-2 ${themeClasses.borderPrimary}`}
|
||||
></div>
|
||||
</div>
|
||||
) : attachmentResults.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* Attachments Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr
|
||||
className={`border-b ${themeClasses.cardBorder}`}
|
||||
>
|
||||
<th
|
||||
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
Title
|
||||
</th>
|
||||
<th
|
||||
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
File Type
|
||||
</th>
|
||||
<th
|
||||
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
Size
|
||||
</th>
|
||||
<th
|
||||
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
className={`text-center py-3 px-4 font-medium ${themeClasses.textPrimary}`}
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attachmentResults.map((attachment) => (
|
||||
<AttachmentRow
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
getThemeClasses={getThemeClasses}
|
||||
onRowClick={handleRowClick}
|
||||
onViewClick={handleViewAttachment}
|
||||
onDeleteClick={handleDeleteAttachment}
|
||||
viewPath={viewPath}
|
||||
showDeleteButton={!!onDeleteAttachment}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row justify-between items-center pt-4 border-t ${themeClasses.cardBorder} gap-4`}
|
||||
>
|
||||
<div
|
||||
className={`text-sm ${themeClasses.textSecondary}`}
|
||||
>
|
||||
Showing {attachmentResults.length} of{" "}
|
||||
{attachmentCount} attachments
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPreviousClick}
|
||||
disabled={
|
||||
!previousCursors || previousCursors.length === 0
|
||||
}
|
||||
size="sm"
|
||||
icon={ChevronLeftIcon}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onNextClick}
|
||||
disabled={!hasNextPage}
|
||||
size="sm"
|
||||
icon={ChevronRightIcon}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="text-center py-12">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-16 h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
|
||||
>
|
||||
<PaperClipIcon
|
||||
className={`w-8 h-8 ${themeClasses.textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
No attachments found
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm ${themeClasses.textSecondary} mb-4`}
|
||||
>
|
||||
This {entityType} doesn't have any attachments yet.
|
||||
</p>
|
||||
{canAdd && addPath && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddAttachment}
|
||||
icon={PlusCircleIcon}
|
||||
>
|
||||
Add First Attachment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* No Data State */
|
||||
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
|
||||
{headerConfig?.icon && (
|
||||
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
|
||||
)}
|
||||
{headerConfig?.notFoundTitle || `${entityType} Not Found`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`${themeClasses.bgCard} border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
|
||||
>
|
||||
<div className="px-4 sm:px-6 py-8 sm:py-16 text-center">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-12 sm:w-16 h-12 sm:h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
|
||||
>
|
||||
<PaperClipIcon
|
||||
className={`w-6 sm:w-8 h-6 sm:h-8 ${themeClasses.textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-base sm:text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
{headerConfig?.notFoundTitle || `${entityType} Not Found`}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm sm:text-base ${themeClasses.textSecondary} mb-4 sm:mb-6`}
|
||||
>
|
||||
{headerConfig?.notFoundMessage ||
|
||||
`The ${entityType} you're looking for doesn't exist or you don't have permission to view it.`}
|
||||
</p>
|
||||
{headerConfig?.notFoundAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={headerConfig.notFoundAction.onClick}
|
||||
icon={headerConfig.notFoundAction.icon}
|
||||
size="sm"
|
||||
>
|
||||
{headerConfig.notFoundAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Optimized comparison - only check props that would actually cause visual changes
|
||||
return (
|
||||
prevProps.entityId === nextProps.entityId &&
|
||||
prevProps.entityType === nextProps.entityType &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.isRefreshing === nextProps.isRefreshing &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.canAdd === nextProps.canAdd &&
|
||||
prevProps.addPath === nextProps.addPath &&
|
||||
prevProps.viewPath === nextProps.viewPath &&
|
||||
prevProps.editPath === nextProps.editPath &&
|
||||
prevProps.deletePath === nextProps.deletePath &&
|
||||
prevProps.pageSize === nextProps.pageSize &&
|
||||
prevProps.nextCursor === nextProps.nextCursor &&
|
||||
// Reference equality for memoized objects (parent should memoize these)
|
||||
prevProps.breadcrumbItems === nextProps.breadcrumbItems &&
|
||||
prevProps.headerConfig === nextProps.headerConfig &&
|
||||
prevProps.fieldSections === nextProps.fieldSections &&
|
||||
prevProps.actionButtons === nextProps.actionButtons &&
|
||||
prevProps.tabs === nextProps.tabs &&
|
||||
prevProps.alerts === nextProps.alerts &&
|
||||
prevProps.previousCursors === nextProps.previousCursors &&
|
||||
// Check entityData key properties instead of deep equality
|
||||
(() => {
|
||||
if (prevProps.entityData === nextProps.entityData) return true;
|
||||
if (!prevProps.entityData || !nextProps.entityData) return false;
|
||||
return (
|
||||
prevProps.entityData.id === nextProps.entityData.id &&
|
||||
prevProps.entityData.status === nextProps.entityData.status
|
||||
);
|
||||
})() &&
|
||||
// Check attachments key properties
|
||||
(() => {
|
||||
if (prevProps.attachments === nextProps.attachments) return true;
|
||||
if (!prevProps.attachments || !nextProps.attachments) return false;
|
||||
return (
|
||||
prevProps.attachments.count === nextProps.attachments.count &&
|
||||
prevProps.attachments.results?.length === nextProps.attachments.results?.length
|
||||
);
|
||||
})() &&
|
||||
// Simple comparison for error
|
||||
prevProps.error === nextProps.error
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AttachmentsViewInner.displayName = "AttachmentsViewInner";
|
||||
|
||||
// Main wrapper component that provides theme context - optimized
|
||||
const AttachmentsView = memo(
|
||||
function AttachmentsView(props) {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<AttachmentsViewInner {...props} />
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Simple shallow comparison for wrapper
|
||||
return Object.keys(prevProps).every(
|
||||
(key) => prevProps[key] === nextProps[key],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AttachmentsView.displayName = "AttachmentsView";
|
||||
|
||||
export default AttachmentsView;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AttachmentsView.jsx';
|
||||
247
web/maplefile-frontend/src/components/UIX/Avatar/Avatar.jsx
Normal file
247
web/maplefile-frontend/src/components/UIX/Avatar/Avatar.jsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// File Path: src/components/UIX/Avatar/Avatar.jsx
|
||||
// Reusable Avatar component with theme-aware styling - Performance Optimized
|
||||
|
||||
import React, { useState, useMemo, useCallback, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// Size configurations moved outside to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: "w-12 h-12 sm:w-16 sm:h-16",
|
||||
md: "w-16 h-16 sm:w-20 sm:h-20 md:w-24 md:h-24",
|
||||
lg: "w-24 h-24 sm:w-32 sm:h-32 md:w-36 md:h-36 lg:w-40 lg:h-40 xl:w-44 xl:h-44",
|
||||
xl: "w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 lg:w-56 lg:h-56",
|
||||
"2xl": "w-40 h-40 sm:w-48 sm:h-48 md:w-56 md:h-56 lg:w-64 lg:h-64",
|
||||
};
|
||||
|
||||
// Default fallback image path
|
||||
const DEFAULT_FALLBACK_SRC = "/img/placeholder.png";
|
||||
|
||||
// Icon size class is constant - no need to recreate
|
||||
const ICON_SIZE_CLASS = "w-1/2 h-1/2";
|
||||
|
||||
/**
|
||||
* Avatar Component - Performance Optimized
|
||||
* Displays user profile pictures with theme-aware borders and consistent sizing
|
||||
*
|
||||
* Features:
|
||||
* - Theme-aware borders that adapt to blue/red themes
|
||||
* - Multiple size variants (sm, md, lg, xl, 2xl)
|
||||
* - Fallback to placeholder image or icon
|
||||
* - Responsive sizing for mobile and desktop
|
||||
* - Accessible alt text handling
|
||||
* - Optimized performance with comprehensive memoization
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.src - Image source URL
|
||||
* @param {string} props.alt - Alt text for accessibility
|
||||
* @param {string} props.size - Size variant: 'sm', 'md', 'lg', 'xl', '2xl'
|
||||
* @param {string} props.fallbackSrc - Fallback image URL (defaults to /img/placeholder.png)
|
||||
* @param {boolean} props.showFallbackIcon - Whether to show user icon as fallback
|
||||
* @param {string} props.className - Additional CSS classes
|
||||
* @param {string} props.borderStyle - Border style: 'default', 'thick', 'none'
|
||||
* @param {function} props.onLoad - Callback when image loads successfully
|
||||
* @param {function} props.onError - Callback when image fails to load
|
||||
*/
|
||||
const Avatar = memo(
|
||||
function Avatar({
|
||||
src,
|
||||
alt = "Profile Picture",
|
||||
size = "lg",
|
||||
fallbackSrc = DEFAULT_FALLBACK_SRC,
|
||||
showFallbackIcon = false,
|
||||
className = "",
|
||||
borderStyle = "default",
|
||||
onLoad,
|
||||
onError,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Track image loading state
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [fallbackError, setFallbackError] = useState(false);
|
||||
|
||||
// Memoize theme classes to prevent multiple calls
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize size class
|
||||
const sizeClass = useMemo(
|
||||
() => SIZE_CLASSES[size] || SIZE_CLASSES.lg,
|
||||
[size],
|
||||
);
|
||||
|
||||
// Memoize border classes based on style
|
||||
const borderClass = useMemo(() => {
|
||||
switch (borderStyle) {
|
||||
case "none":
|
||||
return "";
|
||||
case "thick":
|
||||
return `border-4 ${themeClasses.cardBorder}`;
|
||||
case "default":
|
||||
default:
|
||||
return `border-2 ${themeClasses.cardBorder}`;
|
||||
}
|
||||
}, [borderStyle, themeClasses.cardBorder]);
|
||||
|
||||
// Memoize the image source and alt text
|
||||
const { imageSrc, imageAlt } = useMemo(() => {
|
||||
// If main image has error, try fallback
|
||||
if (imageError && fallbackSrc && !fallbackError) {
|
||||
return {
|
||||
imageSrc: fallbackSrc,
|
||||
imageAlt: "No Profile Picture",
|
||||
};
|
||||
}
|
||||
|
||||
// Use main image if available
|
||||
if (src && src !== "") {
|
||||
return {
|
||||
imageSrc: src,
|
||||
imageAlt: alt,
|
||||
};
|
||||
}
|
||||
|
||||
// Use fallback by default
|
||||
return {
|
||||
imageSrc: fallbackSrc,
|
||||
imageAlt: "No Profile Picture",
|
||||
};
|
||||
}, [src, alt, fallbackSrc, imageError, fallbackError]);
|
||||
|
||||
// Build all classes once
|
||||
const classes = useMemo(() => {
|
||||
const baseClasses = [
|
||||
sizeClass,
|
||||
"rounded-2xl",
|
||||
"shadow-sm",
|
||||
"mx-auto",
|
||||
"xl:mx-0",
|
||||
];
|
||||
|
||||
if (borderClass) {
|
||||
baseClasses.push(borderClass);
|
||||
}
|
||||
|
||||
const base = baseClasses.join(" ");
|
||||
|
||||
return {
|
||||
container: `relative inline-block ${className}`,
|
||||
image: `${base} object-cover`,
|
||||
iconContainer: `${base} ${themeClasses.bgDisabled} flex items-center justify-center ${themeClasses.textMuted}`,
|
||||
};
|
||||
}, [
|
||||
sizeClass,
|
||||
borderClass,
|
||||
className,
|
||||
themeClasses.bgDisabled,
|
||||
themeClasses.textMuted,
|
||||
]);
|
||||
|
||||
// Handle image load error with memoized callback
|
||||
const handleImageError = useCallback(
|
||||
(e) => {
|
||||
const currentSrc = e.target.src;
|
||||
|
||||
// Check if this is the main image or fallback failing
|
||||
if (currentSrc === src) {
|
||||
setImageError(true);
|
||||
} else if (currentSrc === fallbackSrc) {
|
||||
setFallbackError(true);
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
onError(e);
|
||||
}
|
||||
},
|
||||
[src, fallbackSrc, onError],
|
||||
);
|
||||
|
||||
// Handle successful image load with memoized callback
|
||||
const handleImageLoad = useCallback(
|
||||
(e) => {
|
||||
const currentSrc = e.target.src;
|
||||
|
||||
// Reset error state if image loads successfully
|
||||
if (currentSrc === src) {
|
||||
setImageError(false);
|
||||
} else if (currentSrc === fallbackSrc) {
|
||||
setFallbackError(false);
|
||||
}
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(e);
|
||||
}
|
||||
},
|
||||
[src, fallbackSrc, onLoad],
|
||||
);
|
||||
|
||||
// Determine whether to show icon fallback
|
||||
const shouldShowIcon = useMemo(
|
||||
() =>
|
||||
showFallbackIcon &&
|
||||
((!src && !fallbackSrc) ||
|
||||
(imageError && fallbackError) ||
|
||||
(imageError && !fallbackSrc)),
|
||||
[showFallbackIcon, src, fallbackSrc, imageError, fallbackError],
|
||||
);
|
||||
|
||||
// Render icon fallback if needed
|
||||
if (shouldShowIcon) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div
|
||||
className={classes.iconContainer}
|
||||
role="img"
|
||||
aria-label={imageAlt}
|
||||
>
|
||||
<UserIcon className={ICON_SIZE_CLASS} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render image
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
className={classes.image}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for memo - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.src === nextProps.src &&
|
||||
prevProps.alt === nextProps.alt &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.fallbackSrc === nextProps.fallbackSrc &&
|
||||
prevProps.showFallbackIcon === nextProps.showFallbackIcon &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.borderStyle === nextProps.borderStyle &&
|
||||
prevProps.onLoad === nextProps.onLoad &&
|
||||
prevProps.onError === nextProps.onError
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Set display name for debugging
|
||||
Avatar.displayName = "Avatar";
|
||||
|
||||
export default Avatar;
|
||||
|
||||
// Export for reuse in other components
|
||||
export { Avatar };
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// File Path: web/frontend/src/components/UIX/BackButton/BackButton.jsx
|
||||
// BackButton Component - Performance Optimized
|
||||
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Size configurations moved outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: {
|
||||
button: "px-3 py-2 text-xs sm:text-sm",
|
||||
icon: "h-3 w-3 sm:h-4 sm:w-4",
|
||||
},
|
||||
md: {
|
||||
button: "px-5 py-3 text-sm sm:text-base",
|
||||
icon: "h-4 w-4 sm:h-5 sm:w-5",
|
||||
},
|
||||
lg: {
|
||||
button: "px-6 py-4 text-base sm:text-lg",
|
||||
icon: "h-5 w-5 sm:h-6 sm:w-6",
|
||||
},
|
||||
xl: {
|
||||
button: "px-8 py-5 text-lg sm:text-xl",
|
||||
icon: "h-6 w-6 sm:h-7 sm:w-7",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* BackButton Component - Performance Optimized
|
||||
* Navigation button with consistent theming and accessibility
|
||||
*
|
||||
* @param {string} to - Navigation destination path
|
||||
* @param {string} label - Custom label text for the button
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {string} size - Button size (sm, md, lg, xl)
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to ChevronLeftIcon)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {Function} onClick - Optional click handler
|
||||
*/
|
||||
const BackButton = memo(
|
||||
function BackButton({
|
||||
to,
|
||||
label = "Back",
|
||||
disabled = false,
|
||||
size = "lg",
|
||||
icon: Icon = ChevronLeftIcon,
|
||||
className = "",
|
||||
onClick,
|
||||
...props
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Get size classes
|
||||
const sizeConfig = useMemo(
|
||||
() => SIZE_CLASSES[size] || SIZE_CLASSES.lg,
|
||||
[size],
|
||||
);
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
buttonSecondary:
|
||||
getThemeClasses("button-secondary") ||
|
||||
"bg-gray-200 hover:bg-gray-300 text-gray-800",
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize all classes at once
|
||||
const classes = useMemo(() => {
|
||||
// Base button classes
|
||||
const baseClasses = [
|
||||
sizeConfig.button,
|
||||
"font-medium",
|
||||
"rounded-lg",
|
||||
"transition-colors",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
themeClasses.buttonSecondary,
|
||||
];
|
||||
|
||||
// Add disabled state classes
|
||||
if (disabled) {
|
||||
baseClasses.push("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
baseClasses.push("cursor-pointer");
|
||||
}
|
||||
|
||||
// Add custom className if provided
|
||||
if (className) {
|
||||
baseClasses.push(className);
|
||||
}
|
||||
|
||||
return {
|
||||
button: baseClasses.join(" "),
|
||||
icon: `${sizeConfig.icon} mr-2`,
|
||||
};
|
||||
}, [sizeConfig, themeClasses.buttonSecondary, disabled, className]);
|
||||
|
||||
// Memoize click handler
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[onClick, disabled],
|
||||
);
|
||||
|
||||
// Memoize button content
|
||||
const ButtonContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon && <Icon className={classes.icon} aria-hidden="true" />}
|
||||
<span>{label}</span>
|
||||
</>
|
||||
),
|
||||
[Icon, classes.icon, label],
|
||||
);
|
||||
|
||||
// Use Link for navigation when 'to' prop is provided and not disabled
|
||||
if (to && !disabled) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={classes.button}
|
||||
onClick={handleClick}
|
||||
aria-label={`Navigate back to ${label}`}
|
||||
{...props}
|
||||
>
|
||||
{ButtonContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Use button when no navigation target or when disabled
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.button}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={`${label} button`}
|
||||
{...props}
|
||||
>
|
||||
{ButtonContent}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.to === nextProps.to &&
|
||||
prevProps.label === nextProps.label &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.onClick === nextProps.onClick
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
BackButton.displayName = "BackButton";
|
||||
|
||||
export default BackButton;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as BackButton } from './BackButton.jsx';
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// File Path: src/components/UIX/BackToDetailsButton/BackToDetailsButton.jsx
|
||||
// BackToDetailsButton Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// Static base classes - moved outside to prevent recreation
|
||||
const BASE_BUTTON_CLASSES = [
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"px-4",
|
||||
"py-2",
|
||||
"border",
|
||||
"border-gray-300",
|
||||
"rounded-lg",
|
||||
"text-sm",
|
||||
"font-medium",
|
||||
"text-gray-700",
|
||||
"bg-white",
|
||||
"hover:bg-gray-50",
|
||||
"hover:border-gray-400",
|
||||
"focus:outline-none",
|
||||
"focus:ring-2",
|
||||
"focus:ring-offset-2",
|
||||
"focus:ring-blue-500",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"shadow-sm",
|
||||
"hover:shadow-md",
|
||||
].join(" ");
|
||||
|
||||
// Icon classes constant
|
||||
const ICON_CLASSES = "w-4 h-4 mr-2";
|
||||
|
||||
/**
|
||||
* BackToDetailsButton Component - Performance Optimized
|
||||
* Navigation button for returning to details page
|
||||
*
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} text - Button text (defaults to "Back to Details")
|
||||
* @param {string} title - Button title/tooltip (defaults to "Return to details page")
|
||||
*/
|
||||
const BackToDetailsButton = memo(
|
||||
function BackToDetailsButton({
|
||||
onClick,
|
||||
className = "",
|
||||
text = "Back to Details",
|
||||
title = "Return to details page",
|
||||
}) {
|
||||
// Memoize button classes only when className changes
|
||||
const buttonClasses = useMemo(() => {
|
||||
if (!className) {
|
||||
return BASE_BUTTON_CLASSES;
|
||||
}
|
||||
return `${BASE_BUTTON_CLASSES} ${className}`;
|
||||
}, [className]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={buttonClasses}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<InformationCircleIcon className={ICON_CLASSES} aria-hidden="true" />
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.text === nextProps.text &&
|
||||
prevProps.title === nextProps.title
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
BackToDetailsButton.displayName = "BackToDetailsButton";
|
||||
|
||||
export default BackToDetailsButton;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './BackToDetailsButton';
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
// File Path: web/frontend/src/components/UIX/BackToListButton/BackToListButton.jsx
|
||||
// BackToListButton Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* BackToListButton Component - Performance Optimized
|
||||
*
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {string} to - Navigation path (alternative to onClick)
|
||||
* @param {string} text - Button text (default: "Back to List")
|
||||
* @param {string} label - Alias for text prop
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to ArrowLeftIcon)
|
||||
* @param {string} size - Button size (sm, md, lg)
|
||||
* @param {string} title - Title attribute for accessibility
|
||||
* @param {string} ariaLabel - Aria label for screen readers
|
||||
*/
|
||||
|
||||
// Static configurations - frozen to prevent mutations
|
||||
const SIZE_CLASSES = Object.freeze({
|
||||
sm: {
|
||||
button: "px-3 py-2 text-xs sm:text-sm",
|
||||
icon: "h-3 w-3 sm:h-4 sm:w-4",
|
||||
},
|
||||
md: {
|
||||
button: "px-5 py-3 text-sm sm:text-base",
|
||||
icon: "h-4 w-4 sm:h-5 sm:w-5",
|
||||
},
|
||||
lg: {
|
||||
button: "px-6 py-4 text-base sm:text-lg",
|
||||
icon: "h-5 w-5 sm:h-6 sm:w-6",
|
||||
},
|
||||
});
|
||||
|
||||
// Base classes that never change
|
||||
const BASE_CLASSES = [
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"rounded-xl",
|
||||
"shadow-sm",
|
||||
"font-medium",
|
||||
"focus:outline-none",
|
||||
"focus:ring-2",
|
||||
"focus:ring-offset-2",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
].join(" ");
|
||||
|
||||
const BackToListButton = memo(
|
||||
function BackToListButton({
|
||||
onClick,
|
||||
to,
|
||||
text,
|
||||
label,
|
||||
className = "",
|
||||
disabled = false,
|
||||
icon: Icon = ArrowLeftIcon,
|
||||
size = "md",
|
||||
title,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Support both 'text' and 'label' props, with default
|
||||
const buttonText = text || label || "Back to List";
|
||||
|
||||
// Handle navigation if 'to' prop is provided
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (onClick) {
|
||||
onClick(event);
|
||||
} else if (to) {
|
||||
navigate(to);
|
||||
}
|
||||
},
|
||||
[onClick, to, navigate, disabled],
|
||||
);
|
||||
|
||||
// Get size configuration
|
||||
const sizeConfig = useMemo(
|
||||
() => SIZE_CLASSES[size] || SIZE_CLASSES.md,
|
||||
[size],
|
||||
);
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
buttonSecondary: getThemeClasses("button-secondary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize all classes at once
|
||||
const classes = useMemo(() => {
|
||||
// Build button classes array
|
||||
const buttonClasses = [
|
||||
BASE_CLASSES,
|
||||
sizeConfig.button,
|
||||
themeClasses.buttonSecondary,
|
||||
];
|
||||
|
||||
// Add state classes
|
||||
if (disabled) {
|
||||
buttonClasses.push("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
buttonClasses.push(
|
||||
"cursor-pointer",
|
||||
"hover:shadow-md",
|
||||
"active:shadow-sm",
|
||||
);
|
||||
}
|
||||
|
||||
// Add custom className if provided
|
||||
if (className) {
|
||||
buttonClasses.push(className);
|
||||
}
|
||||
|
||||
// Build icon classes
|
||||
const iconClasses = [sizeConfig.icon, "mr-2", "flex-shrink-0"];
|
||||
|
||||
return {
|
||||
button: buttonClasses.filter(Boolean).join(" "),
|
||||
icon: iconClasses.join(" "),
|
||||
};
|
||||
}, [sizeConfig, themeClasses.buttonSecondary, disabled, className]);
|
||||
|
||||
// Memoize button content
|
||||
const ButtonContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon && <Icon className={classes.icon} aria-hidden="true" />}
|
||||
<span className="truncate">{buttonText}</span>
|
||||
</>
|
||||
),
|
||||
[Icon, classes.icon, buttonText],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={classes.button}
|
||||
aria-label={ariaLabel || buttonText}
|
||||
title={title || buttonText}
|
||||
{...props}
|
||||
>
|
||||
{ButtonContent}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.to === nextProps.to &&
|
||||
prevProps.text === nextProps.text &&
|
||||
prevProps.label === nextProps.label &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.ariaLabel === nextProps.ariaLabel
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
BackToListButton.displayName = "BackToListButton";
|
||||
|
||||
export default BackToListButton;
|
||||
|
||||
// Export size constants for use in other components
|
||||
export const BUTTON_SIZES = Object.freeze({
|
||||
SMALL: "sm",
|
||||
MEDIUM: "md",
|
||||
LARGE: "lg",
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
// File: src/components/UIX/BackupCodeDisplay/BackupCodeDisplay.jsx
|
||||
// Backup Code Display Component for 2FA
|
||||
// Shows backup code with copy functionality and security warnings
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
DocumentDuplicateIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
import useMobileOptimizations from "../hooks/useMobileOptimizations.jsx";
|
||||
|
||||
/**
|
||||
* BackupCodeDisplay Component
|
||||
*
|
||||
* Specialized component for displaying 2FA backup codes with copy functionality
|
||||
* and security warnings. Optimized for mobile devices.
|
||||
*
|
||||
* Features:
|
||||
* - Large, readable backup code display
|
||||
* - One-click copy to clipboard
|
||||
* - Visual feedback on copy success
|
||||
* - Security warnings and best practices
|
||||
* - Mobile-optimized layout
|
||||
* - Theme-aware styling
|
||||
*
|
||||
* @param {string} code - The backup code to display (required)
|
||||
* @param {string} label - Label text (default: "Backup Code:")
|
||||
* @param {boolean} showWarnings - Whether to show security warnings (default: true)
|
||||
* @param {function} onCopy - Optional callback when code is copied
|
||||
* @param {string} className - Additional CSS classes
|
||||
*
|
||||
* @example
|
||||
* <BackupCodeDisplay
|
||||
* code="ABC123XYZ789"
|
||||
* label="Your 2FA Backup Code"
|
||||
* showWarnings={true}
|
||||
* onCopy={() => console.log('Code copied')}
|
||||
* />
|
||||
*/
|
||||
function BackupCodeDisplay({
|
||||
code,
|
||||
label = "Backup Code:",
|
||||
showWarnings = true,
|
||||
onCopy,
|
||||
className = "",
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const { isMobile } = useMobileOptimizations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Handle copy to clipboard
|
||||
const handleCopyCode = async () => {
|
||||
if (!code) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
|
||||
// Call optional callback
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
// Reset copied state after 3 seconds
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy backup code to clipboard", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Header with Copy Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
onClick={handleCopyCode}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
copied
|
||||
? "bg-green-100 text-green-700 border border-green-300"
|
||||
: `bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200 ${getThemeClasses("hover-transform")}`
|
||||
} ${isMobile ? "min-h-[44px]" : ""}`}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Backup Code Display */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
value={code}
|
||||
className={`
|
||||
w-full h-24 sm:h-32 p-4 sm:p-6
|
||||
border-2 border-green-300 rounded-xl
|
||||
bg-green-50 font-mono text-base sm:text-lg font-bold
|
||||
text-center resize-none
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500
|
||||
${isMobile ? "text-xl" : ""}
|
||||
`}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: isMobile ? "16px" : undefined, // Prevent iOS zoom
|
||||
WebkitAppearance: "none",
|
||||
}}
|
||||
/>
|
||||
<div className={`text-xs sm:text-sm ${getThemeClasses("text-secondary")} mt-3 text-center`}>
|
||||
Save this code in a secure location. You'll need it if you lose access
|
||||
to your 2FA device.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Warnings */}
|
||||
{showWarnings && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 sm:p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">
|
||||
Important Security Notes
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-amber-800">
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="w-1.5 h-1.5 bg-amber-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
<span>This backup code can only be used once</span>
|
||||
</li>
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="w-1.5 h-1.5 bg-amber-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
<span>
|
||||
Store it in a secure password manager or safe location
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="w-1.5 h-1.5 bg-amber-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
<span>
|
||||
Never share this code with anyone, including support staff
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start space-x-2">
|
||||
<span className="w-1.5 h-1.5 bg-amber-600 rounded-full mt-2 flex-shrink-0"></span>
|
||||
<span>
|
||||
After using it, you'll need to set up 2FA again to get a new
|
||||
backup code
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Indicator */}
|
||||
{copied && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<div className="flex items-center space-x-2 text-green-700">
|
||||
<CheckCircleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium">
|
||||
Backup code copied to clipboard! Make sure to save it securely.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackupCodeDisplay;
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
# BackupCodeDisplay Component
|
||||
|
||||
A specialized component for displaying 2FA backup codes with copy functionality, security warnings, and mobile optimizations.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **One-click copy** to clipboard with visual feedback
|
||||
- ✅ **Large, readable code** - monospace font, center-aligned
|
||||
- ✅ **Security warnings** - best practices and important notes
|
||||
- ✅ **Mobile-optimized** - proper touch targets and font sizing
|
||||
- ✅ **Theme-aware** - integrates with UIX theme system
|
||||
- ✅ **Accessible** - proper labels and copy confirmations
|
||||
- ✅ **Success indicators** - visual feedback when code is copied
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
import { BackupCodeDisplay } from "components/UIX";
|
||||
|
||||
function ShowBackupCode() {
|
||||
const backupCode = "ABC123XYZ789DEF456";
|
||||
|
||||
return (
|
||||
<BackupCodeDisplay
|
||||
code={backupCode}
|
||||
label="Your 2FA Backup Code"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Copy Callback
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
import { BackupCodeDisplay } from "components/UIX";
|
||||
|
||||
function TrackCopy() {
|
||||
const backupCode = "ABC123XYZ789DEF456";
|
||||
|
||||
const handleCopy = () => {
|
||||
// Track analytics event
|
||||
analytics.track("backup_code_copied");
|
||||
|
||||
// Show additional guidance
|
||||
console.log("User copied backup code");
|
||||
};
|
||||
|
||||
return (
|
||||
<BackupCodeDisplay
|
||||
code={backupCode}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Without Security Warnings
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
import { BackupCodeDisplay } from "components/UIX";
|
||||
|
||||
function CompactDisplay() {
|
||||
return (
|
||||
<BackupCodeDisplay
|
||||
code="ABC123XYZ789DEF456"
|
||||
label="Recovery Code"
|
||||
showWarnings={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete 2FA Backup Code Page
|
||||
|
||||
```jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
BackupCodeDisplay,
|
||||
Button,
|
||||
Card,
|
||||
Alert,
|
||||
UIXThemeProvider,
|
||||
} from "components/UIX";
|
||||
import { useAuthManager } from "services/Services";
|
||||
import { getRoleRedirectPath } from "constants/Roles";
|
||||
|
||||
function BackupCodePage() {
|
||||
const navigate = useNavigate();
|
||||
const authManager = useAuthManager();
|
||||
|
||||
// Get backup code from sessionStorage (set during 2FA setup)
|
||||
const backupCode = sessionStorage.getItem("2fa_backup_code");
|
||||
|
||||
// Remove from sessionStorage immediately after retrieval
|
||||
useEffect(() => {
|
||||
if (backupCode) {
|
||||
sessionStorage.removeItem("2fa_backup_code");
|
||||
}
|
||||
}, [backupCode]);
|
||||
|
||||
const handleContinue = () => {
|
||||
const user = authManager.getCurrentUser();
|
||||
const redirectPath = getRoleRedirectPath(user.role);
|
||||
navigate(redirectPath);
|
||||
};
|
||||
|
||||
if (!backupCode) {
|
||||
return (
|
||||
<Alert type="error">
|
||||
No backup code found. Please complete 2FA setup again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 p-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card className="p-8">
|
||||
<h1 className="text-3xl font-bold text-center mb-6">
|
||||
2FA Setup Complete!
|
||||
</h1>
|
||||
|
||||
<p className="text-center text-gray-600 mb-8">
|
||||
Save this backup code securely. You'll need it if you lose access
|
||||
to your authenticator app.
|
||||
</p>
|
||||
|
||||
<BackupCodeDisplay
|
||||
code={backupCode}
|
||||
showWarnings={true}
|
||||
onCopy={() => console.log("Code copied")}
|
||||
/>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
variant="success"
|
||||
size="lg"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackupCodePage;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `code` | `string` | *required* | The backup code to display |
|
||||
| `label` | `string` | `"Backup Code:"` | Label text for the code section |
|
||||
| `showWarnings` | `boolean` | `true` | Whether to show security warning section |
|
||||
| `onCopy` | `function` | - | Optional callback when code is copied |
|
||||
| `className` | `string` | `""` | Additional CSS classes |
|
||||
|
||||
## Component Sections
|
||||
|
||||
### 1. Header Section
|
||||
- Label text (customizable via `label` prop)
|
||||
- Copy button with icon
|
||||
- Visual feedback when copied (green background, checkmark icon)
|
||||
|
||||
### 2. Code Display
|
||||
- Large textarea with backup code
|
||||
- Monospace font for readability
|
||||
- Center-aligned text
|
||||
- Green border and background
|
||||
- Read-only (prevents accidental editing)
|
||||
- Helper text below
|
||||
|
||||
### 3. Security Warnings (Optional)
|
||||
Amber-colored warning box with important notes:
|
||||
- ✅ Code can only be used once
|
||||
- ✅ Store in password manager or safe location
|
||||
- ✅ Never share with anyone
|
||||
- ✅ Need to set up 2FA again after using it
|
||||
|
||||
### 4. Success Indicator (Conditional)
|
||||
- Shows green success message when code is copied
|
||||
- Auto-dismisses after 3 seconds
|
||||
- Reminds user to save securely
|
||||
|
||||
## Visual States
|
||||
|
||||
### Default State
|
||||
- Gray copy button
|
||||
- Green code display area
|
||||
- Amber warning section
|
||||
|
||||
### Copied State (3 seconds)
|
||||
- Green copy button with checkmark
|
||||
- "Copied!" button text
|
||||
- Green success banner appears
|
||||
- Auto-resets to default after 3 seconds
|
||||
|
||||
### No Code State
|
||||
- Component returns `null`
|
||||
- Does not render anything
|
||||
|
||||
## Mobile Optimizations
|
||||
|
||||
### Touch Targets
|
||||
- Copy button has minimum 44px height on mobile
|
||||
- Proper touch-action handling
|
||||
- No tap highlight color
|
||||
|
||||
### Font Sizing
|
||||
- Prevents iOS zoom with 16px minimum
|
||||
- Larger text on mobile (text-xl)
|
||||
- Responsive sizing with breakpoints
|
||||
|
||||
### Layout
|
||||
- Responsive spacing (sm: breakpoints)
|
||||
- Proper safe area handling
|
||||
- Mobile-friendly touch interactions
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Use sessionStorage (Not URL Parameters)
|
||||
```jsx
|
||||
// ✅ CORRECT - Secure
|
||||
sessionStorage.setItem("2fa_backup_code", code);
|
||||
navigate("/backup-code");
|
||||
|
||||
// Component retrieves and removes
|
||||
const code = sessionStorage.getItem("2fa_backup_code");
|
||||
sessionStorage.removeItem("2fa_backup_code");
|
||||
|
||||
// ❌ INCORRECT - Insecure (code in URL/logs)
|
||||
navigate(`/backup-code?code=${code}`);
|
||||
```
|
||||
|
||||
### 2. Remove After Display
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
if (backupCode) {
|
||||
sessionStorage.removeItem("2fa_backup_code");
|
||||
}
|
||||
}, [backupCode]);
|
||||
```
|
||||
|
||||
### 3. Validate Code Exists
|
||||
```jsx
|
||||
if (!backupCode) {
|
||||
return <Alert type="error">No code found</Alert>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Track Copy Events
|
||||
```jsx
|
||||
<BackupCodeDisplay
|
||||
code={code}
|
||||
onCopy={() => {
|
||||
analytics.track("backup_code_copied");
|
||||
logSecurityEvent("BACKUP_CODE_VIEWED");
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Proper label/button associations
|
||||
- Visual copy feedback (not just color)
|
||||
- Success message announced to screen readers
|
||||
- Keyboard accessible copy button
|
||||
- High contrast text and borders
|
||||
|
||||
## Theme Integration
|
||||
|
||||
The component is fully theme-aware:
|
||||
- Label uses `text-primary` theme class
|
||||
- Helper text uses `text-secondary` theme class
|
||||
- Hover states use theme transform utilities
|
||||
- Works with all UIX themes (blue, red, purple, green, charcoal)
|
||||
|
||||
## Copy Functionality
|
||||
|
||||
The component uses the modern Clipboard API:
|
||||
|
||||
```javascript
|
||||
await navigator.clipboard.writeText(code);
|
||||
```
|
||||
|
||||
### Browser Support
|
||||
- ✅ Chrome/Edge 66+
|
||||
- ✅ Firefox 63+
|
||||
- ✅ Safari 13.1+
|
||||
- ✅ Mobile browsers (iOS Safari 13+, Chrome Mobile)
|
||||
|
||||
### Error Handling
|
||||
```javascript
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy", error);
|
||||
// Optionally show error to user
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Integration Example
|
||||
|
||||
### Step 1: Generate Backup Code (Step3Page.jsx)
|
||||
```jsx
|
||||
const verifyResponse = await twoFactorAuthManager.verifyOTP(token);
|
||||
|
||||
if (verifyResponse.otp_backup_code) {
|
||||
// Store in sessionStorage (NOT URL)
|
||||
sessionStorage.setItem("2fa_backup_code", verifyResponse.otp_backup_code);
|
||||
navigate("/login/2fa/backup-code");
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Display Backup Code (BackupCodeGeneratePage.jsx)
|
||||
```jsx
|
||||
// Retrieve and remove from sessionStorage
|
||||
const backupCode = sessionStorage.getItem("2fa_backup_code");
|
||||
if (backupCode) {
|
||||
sessionStorage.removeItem("2fa_backup_code");
|
||||
}
|
||||
|
||||
return (
|
||||
<BackupCodeDisplay
|
||||
code={backupCode}
|
||||
onCopy={() => analytics.track("backup_code_copied")}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Related Components
|
||||
|
||||
- `OTPInput` - For entering verification codes
|
||||
- `Alert` - For error/success messages
|
||||
- `Card` - For page layout
|
||||
- `Button` - For navigation actions
|
||||
- `useMobileOptimizations` - Hook for mobile device detection
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Remove from Storage After Display
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
sessionStorage.removeItem("2fa_backup_code");
|
||||
}
|
||||
}, [code]);
|
||||
```
|
||||
|
||||
### 2. Show Security Warnings by Default
|
||||
```jsx
|
||||
<BackupCodeDisplay
|
||||
code={code}
|
||||
showWarnings={true} // Default, emphasizes security
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. Provide Next Step Navigation
|
||||
```jsx
|
||||
<BackupCodeDisplay code={code} />
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 4. Track Security Events
|
||||
```jsx
|
||||
<BackupCodeDisplay
|
||||
code={code}
|
||||
onCopy={() => {
|
||||
logSecurityEvent("BACKUP_CODE_COPIED");
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Migration from Raw HTML
|
||||
|
||||
### Before (Raw HTML - 100+ lines)
|
||||
```jsx
|
||||
<textarea readOnly value={backupCode} className="..." style={{...}} />
|
||||
<button onClick={handleCopy} className="...">
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
<div className="warning-box">
|
||||
<ul>
|
||||
<li>Security note 1</li>
|
||||
<li>Security note 2</li>
|
||||
{/* etc... */}
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### After (UIX Component - 1 line)
|
||||
```jsx
|
||||
<BackupCodeDisplay code={backupCode} onCopy={handleCopy} />
|
||||
```
|
||||
|
||||
**Result**: ~100 lines reduced to 1 line, with better UX and security.
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./BackupCodeDisplay";
|
||||
243
web/maplefile-frontend/src/components/UIX/Badge/Badge.jsx
Normal file
243
web/maplefile-frontend/src/components/UIX/Badge/Badge.jsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// File: src/components/UI/Badge/Badge.jsx
|
||||
// Badge Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Badge Component - Performance Optimized
|
||||
* Small label or indicator for counts, statuses, or categories
|
||||
*
|
||||
* @param {React.ReactNode} children - Badge content
|
||||
* @param {string} variant - Badge style variant
|
||||
* @param {string} size - Badge size: 'sm', 'md', 'lg'
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} ariaLabel - Accessibility label
|
||||
* @param {boolean} dot - Show as dot indicator only
|
||||
* @param {boolean} animate - Add pulse animation
|
||||
* @param {Function} onClick - Optional click handler
|
||||
*/
|
||||
|
||||
// Static configurations - frozen to prevent mutations
|
||||
const SIZE_CLASSES = Object.freeze({
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
md: "px-2.5 py-1 text-sm",
|
||||
lg: "px-3 py-1.5 text-base",
|
||||
});
|
||||
|
||||
const DOT_SIZE_CLASSES = Object.freeze({
|
||||
sm: "h-2 w-2",
|
||||
md: "h-2.5 w-2.5",
|
||||
lg: "h-3 w-3",
|
||||
});
|
||||
|
||||
// Base classes that never change
|
||||
const BASE_CLASSES =
|
||||
"inline-flex items-center font-medium rounded-full transition-colors duration-150";
|
||||
|
||||
// Variant to theme mapping
|
||||
const VARIANT_THEME_KEYS = Object.freeze({
|
||||
default: "badge-default",
|
||||
primary: "badge-primary",
|
||||
success: "badge-success",
|
||||
warning: "badge-warning",
|
||||
error: "badge-error",
|
||||
danger: "badge-error",
|
||||
info: "badge-info",
|
||||
secondary: "badge-secondary",
|
||||
});
|
||||
|
||||
// Default fallback classes if theme is not available
|
||||
const FALLBACK_VARIANT_CLASSES = Object.freeze({
|
||||
default: "bg-gray-100 text-gray-800",
|
||||
primary: "bg-blue-100 text-blue-800",
|
||||
success: "bg-green-100 text-green-800",
|
||||
warning: "bg-yellow-100 text-yellow-800",
|
||||
error: "bg-red-100 text-red-800",
|
||||
danger: "bg-red-100 text-red-800",
|
||||
info: "bg-cyan-100 text-cyan-800",
|
||||
secondary: "bg-gray-100 text-gray-600",
|
||||
});
|
||||
|
||||
const Badge = memo(
|
||||
function Badge({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
className = "",
|
||||
ariaLabel,
|
||||
dot = false,
|
||||
animate = false,
|
||||
onClick,
|
||||
...props
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(() => {
|
||||
const themeKey =
|
||||
VARIANT_THEME_KEYS[variant] || VARIANT_THEME_KEYS.default;
|
||||
const classes = getThemeClasses(themeKey);
|
||||
|
||||
return (
|
||||
classes ||
|
||||
FALLBACK_VARIANT_CLASSES[variant] ||
|
||||
FALLBACK_VARIANT_CLASSES.default
|
||||
);
|
||||
}, [variant, getThemeClasses]);
|
||||
|
||||
// Memoize all classes at once
|
||||
const classes = useMemo(() => {
|
||||
// Handle dot mode separately
|
||||
if (dot) {
|
||||
const dotSize = DOT_SIZE_CLASSES[size] || DOT_SIZE_CLASSES.md;
|
||||
const dotClasses = [
|
||||
"inline-block",
|
||||
"rounded-full",
|
||||
dotSize,
|
||||
themeClasses,
|
||||
];
|
||||
|
||||
if (animate) {
|
||||
dotClasses.push("animate-pulse");
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
dotClasses.push("cursor-pointer", "hover:opacity-80");
|
||||
}
|
||||
|
||||
if (className) {
|
||||
dotClasses.push(className);
|
||||
}
|
||||
|
||||
return dotClasses.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// Regular badge classes
|
||||
const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.md;
|
||||
const badgeClasses = [BASE_CLASSES, sizeClass, themeClasses];
|
||||
|
||||
if (animate) {
|
||||
badgeClasses.push("animate-pulse");
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
badgeClasses.push(
|
||||
"cursor-pointer",
|
||||
"hover:opacity-80",
|
||||
"active:opacity-60",
|
||||
);
|
||||
}
|
||||
|
||||
if (className) {
|
||||
badgeClasses.push(className);
|
||||
}
|
||||
|
||||
return badgeClasses.filter(Boolean).join(" ");
|
||||
}, [size, themeClasses, dot, animate, onClick, className]);
|
||||
|
||||
// Memoize accessibility props
|
||||
const accessibilityProps = useMemo(() => {
|
||||
const baseProps = {
|
||||
"aria-label":
|
||||
ariaLabel || (typeof children === "string" ? children : undefined),
|
||||
};
|
||||
|
||||
// Add role based on usage
|
||||
if (onClick) {
|
||||
baseProps.role = "button";
|
||||
baseProps.tabIndex = 0;
|
||||
} else if (
|
||||
variant === "error" ||
|
||||
variant === "danger" ||
|
||||
variant === "warning"
|
||||
) {
|
||||
baseProps.role = "alert";
|
||||
} else {
|
||||
baseProps.role = "status";
|
||||
}
|
||||
|
||||
// Add live region for dynamic badges
|
||||
if (animate || baseProps.role === "alert") {
|
||||
baseProps["aria-live"] = "polite";
|
||||
}
|
||||
|
||||
return baseProps;
|
||||
}, [ariaLabel, children, onClick, variant, animate]);
|
||||
|
||||
// Memoize keyboard handler for clickable badges
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (!onClick) return;
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
// Render dot indicator mode
|
||||
if (dot) {
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||
{...accessibilityProps}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render regular badge with content
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||
{...accessibilityProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.ariaLabel === nextProps.ariaLabel &&
|
||||
prevProps.dot === nextProps.dot &&
|
||||
prevProps.animate === nextProps.animate &&
|
||||
prevProps.onClick === nextProps.onClick
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export default Badge;
|
||||
|
||||
// Export common variants for consistency
|
||||
export const BADGE_VARIANTS = Object.freeze({
|
||||
DEFAULT: "default",
|
||||
PRIMARY: "primary",
|
||||
SUCCESS: "success",
|
||||
WARNING: "warning",
|
||||
ERROR: "error",
|
||||
DANGER: "danger",
|
||||
INFO: "info",
|
||||
SECONDARY: "secondary",
|
||||
});
|
||||
|
||||
export const BADGE_SIZES = Object.freeze({
|
||||
SMALL: "sm",
|
||||
MEDIUM: "md",
|
||||
LARGE: "lg",
|
||||
});
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
// File: src/components/UIX/Breadcrumb/Breadcrumb.jsx
|
||||
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Breadcrumb Component - FULLY OPTIMIZED VERSION
|
||||
*
|
||||
* Performance optimizations included:
|
||||
* - Component memoization to prevent unnecessary re-renders
|
||||
* - Static configurations moved outside component
|
||||
* - Memoized class computations
|
||||
* - Optimized theme class retrieval (single call per render)
|
||||
* - Separated BreadcrumbItem component for better performance
|
||||
* - Accessibility improvements
|
||||
* - Keyboard navigation support
|
||||
*
|
||||
* Modern navigation aid with rounded design and blue theme
|
||||
* Follows the admin style guide standards
|
||||
*
|
||||
* @param {Array} items - Array of breadcrumb items:
|
||||
* - label: Display text (required)
|
||||
* - to: React Router path (optional - for Link components)
|
||||
* - href: External URL (optional - for anchor tags)
|
||||
* - onClick: Click handler (optional)
|
||||
* - icon: Heroicon component (optional)
|
||||
* - isActive: Boolean to mark current page (optional)
|
||||
* - disabled: Boolean to disable interaction (optional)
|
||||
* @param {string} className - Additional CSS classes for container
|
||||
* @param {string} separator - Custom separator element (optional)
|
||||
* @param {string} ariaLabel - Custom aria label (optional)
|
||||
* @param {Object} ...props - Additional props to pass to nav element
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// STATIC CONFIGURATIONS
|
||||
// Move outside component to prevent recreation on each render
|
||||
// ============================================
|
||||
|
||||
// Shadow styles (created once, reused)
|
||||
const SHADOW_STYLE = Object.freeze({
|
||||
boxShadow:
|
||||
"0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||
});
|
||||
|
||||
// Base classes that never change
|
||||
const NAV_BASE_CLASSES = "flex mb-6 sm:mb-8 lg:mb-10";
|
||||
const LIST_BASE_CLASSES =
|
||||
"inline-flex items-center space-x-1 md:space-x-3 whitespace-nowrap rounded-full px-6 py-3 shadow-xl border-2";
|
||||
const ITEM_BASE_CLASSES = "inline-flex items-center";
|
||||
const LINK_BASE_CLASSES =
|
||||
"text-sm sm:text-base font-medium transition-all duration-200 px-3 py-1 rounded-full inline-flex items-center";
|
||||
const ACTIVE_BASE_CLASSES =
|
||||
"ml-1 text-sm sm:text-base font-semibold px-3 py-1 md:ml-2 inline-flex items-center";
|
||||
const ICON_BASE_CLASSES = "w-4 h-4 mr-2 flex-shrink-0";
|
||||
const SEPARATOR_BASE_CLASSES = "w-5 h-5 mx-1 flex-shrink-0";
|
||||
|
||||
// ============================================
|
||||
// BREADCRUMB ITEM COMPONENT
|
||||
// Separated for better performance and memoization
|
||||
// ============================================
|
||||
|
||||
const BreadcrumbItem = memo(function BreadcrumbItem({
|
||||
item,
|
||||
index,
|
||||
isLast,
|
||||
separator,
|
||||
themeClasses,
|
||||
}) {
|
||||
const isActive = item.isActive || isLast;
|
||||
const isDisabled = item.disabled || false;
|
||||
|
||||
// ============================================
|
||||
// CLICK HANDLER WITH SAFETY CHECKS
|
||||
// ============================================
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
if (isDisabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.onClick && typeof item.onClick === "function") {
|
||||
item.onClick(event);
|
||||
}
|
||||
},
|
||||
[item.onClick, isDisabled],
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// KEYBOARD HANDLER
|
||||
// ============================================
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (isDisabled) return;
|
||||
|
||||
// Handle Enter and Space for clickable items
|
||||
if ((event.key === "Enter" || event.key === " ") && item.onClick) {
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
}
|
||||
},
|
||||
[handleClick, item.onClick, isDisabled],
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// MEMOIZED CLASSES
|
||||
// ============================================
|
||||
|
||||
const linkClasses = useMemo(() => {
|
||||
const classes = [
|
||||
LINK_BASE_CLASSES,
|
||||
themeClasses.inactive,
|
||||
isDisabled && "opacity-50 cursor-not-allowed",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return classes;
|
||||
}, [themeClasses.inactive, isDisabled]);
|
||||
|
||||
const activeClasses = useMemo(() => {
|
||||
return `${ACTIVE_BASE_CLASSES} ${themeClasses.active}`;
|
||||
}, [themeClasses.active]);
|
||||
|
||||
// ============================================
|
||||
// RENDER SEPARATOR
|
||||
// ============================================
|
||||
|
||||
const renderSeparator = () => {
|
||||
if (index === 0) return null;
|
||||
|
||||
if (separator && typeof separator === "function") {
|
||||
return separator();
|
||||
}
|
||||
|
||||
if (separator) {
|
||||
return (
|
||||
<span className={`${SEPARATOR_BASE_CLASSES} ${themeClasses.separator}`}>
|
||||
{separator}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={`${SEPARATOR_BASE_CLASSES} ${themeClasses.separator}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// RENDER ICON
|
||||
// ============================================
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!item.icon) return null;
|
||||
|
||||
const Icon = item.icon;
|
||||
return <Icon className={ICON_BASE_CLASSES} aria-hidden="true" />;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// RENDER CONTENT
|
||||
// ============================================
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{renderIcon()}
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// RENDER LINK/SPAN BASED ON STATE
|
||||
// ============================================
|
||||
|
||||
const renderItem = () => {
|
||||
// Active/Last item - non-interactive
|
||||
if (isActive) {
|
||||
return (
|
||||
<span className={activeClasses} aria-current="page">
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// React Router Link
|
||||
if (item.to && !isDisabled) {
|
||||
return (
|
||||
<Link
|
||||
to={item.to}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={linkClasses}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// External Link
|
||||
if (item.href && !isDisabled) {
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={linkClasses}
|
||||
target={item.target}
|
||||
rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Clickable span (for custom onClick without navigation)
|
||||
if (item.onClick && !isDisabled) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={linkClasses}
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain text (no interaction)
|
||||
return <span className={linkClasses}>{content}</span>;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// RENDER
|
||||
// ============================================
|
||||
|
||||
return (
|
||||
<li className={ITEM_BASE_CLASSES}>
|
||||
{renderSeparator()}
|
||||
{renderItem()}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
// ============================================
|
||||
// MAIN BREADCRUMB COMPONENT
|
||||
// ============================================
|
||||
|
||||
const Breadcrumb = memo(function Breadcrumb({
|
||||
items = [],
|
||||
className = "",
|
||||
separator,
|
||||
ariaLabel = "Breadcrumb",
|
||||
...props
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// ============================================
|
||||
// MEMOIZED THEME CLASSES
|
||||
// Single call to getThemeClasses per theme key
|
||||
// ============================================
|
||||
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
bgCard: getThemeClasses("bg-card") || "bg-white",
|
||||
cardBorder: getThemeClasses("card-border") || "border-gray-200",
|
||||
inactive:
|
||||
getThemeClasses("breadcrumb-inactive") ||
|
||||
"text-gray-600 hover:text-gray-900 hover:bg-gray-100",
|
||||
active: getThemeClasses("breadcrumb-active") || "text-gray-900",
|
||||
separator: getThemeClasses("text-muted") || "text-gray-400",
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// MEMOIZED CONTAINER CLASSES
|
||||
// ============================================
|
||||
|
||||
const containerClasses = useMemo(() => {
|
||||
return [NAV_BASE_CLASSES, className].filter(Boolean).join(" ");
|
||||
}, [className]);
|
||||
|
||||
const listClasses = useMemo(() => {
|
||||
return [LIST_BASE_CLASSES, themeClasses.bgCard, themeClasses.cardBorder]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}, [themeClasses.bgCard, themeClasses.cardBorder]);
|
||||
|
||||
// ============================================
|
||||
// EARLY RETURN FOR EMPTY ITEMS
|
||||
// ============================================
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RENDER
|
||||
// ============================================
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<nav aria-label={ariaLabel} {...props}>
|
||||
<ol className={listClasses} style={SHADOW_STYLE}>
|
||||
{items.map((item, index) => (
|
||||
<BreadcrumbItem
|
||||
key={item.key || item.label || index}
|
||||
item={item}
|
||||
index={index}
|
||||
isLast={index === items.length - 1}
|
||||
separator={separator}
|
||||
themeClasses={themeClasses}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// DISPLAY NAME FOR DEBUGGING
|
||||
// ============================================
|
||||
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
// ============================================
|
||||
// PROP TYPES (Optional but recommended)
|
||||
// ============================================
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
try {
|
||||
const PropTypes = require("prop-types");
|
||||
|
||||
Breadcrumb.propTypes = {
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
to: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
icon: PropTypes.elementType,
|
||||
isActive: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
target: PropTypes.string,
|
||||
key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
}),
|
||||
),
|
||||
className: PropTypes.string,
|
||||
separator: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
ariaLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
BreadcrumbItem.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
separator: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
themeClasses: PropTypes.object.isRequired,
|
||||
};
|
||||
} catch (e) {
|
||||
// PropTypes not installed
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER HOOK FOR BREADCRUMB GENERATION
|
||||
// ============================================
|
||||
|
||||
export const useBreadcrumbItems = (pathname, routeConfig = {}) => {
|
||||
return useMemo(() => {
|
||||
const paths = pathname.split("/").filter(Boolean);
|
||||
const items = [];
|
||||
|
||||
// Always add home
|
||||
items.push({
|
||||
label: "Home",
|
||||
to: "/",
|
||||
key: "home",
|
||||
});
|
||||
|
||||
// Build path progressively
|
||||
let currentPath = "";
|
||||
paths.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const isLast = index === paths.length - 1;
|
||||
|
||||
// Get label from config or format segment
|
||||
const label =
|
||||
routeConfig[currentPath] ||
|
||||
segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, " ");
|
||||
|
||||
items.push({
|
||||
label,
|
||||
to: isLast ? undefined : currentPath,
|
||||
key: currentPath,
|
||||
isActive: isLast,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [pathname, routeConfig]);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// EXPORTS
|
||||
// ============================================
|
||||
|
||||
export default Breadcrumb;
|
||||
|
||||
// Named exports for convenience
|
||||
export { BreadcrumbItem };
|
||||
|
||||
// ============================================
|
||||
// USAGE EXAMPLES (in comments for documentation)
|
||||
// ============================================
|
||||
|
||||
/*
|
||||
// Basic usage:
|
||||
const items = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Products', to: '/products' },
|
||||
{ label: 'Electronics', to: '/products/electronics' },
|
||||
{ label: 'Laptops' } // Current page (no 'to')
|
||||
];
|
||||
<Breadcrumb items={items} />
|
||||
|
||||
// With icons:
|
||||
import { HomeIcon, FolderIcon } from '@heroicons/react/24/outline';
|
||||
const items = [
|
||||
{ label: 'Home', to: '/', icon: HomeIcon },
|
||||
{ label: 'Documents', to: '/docs', icon: FolderIcon },
|
||||
{ label: 'Report.pdf' }
|
||||
];
|
||||
<Breadcrumb items={items} />
|
||||
|
||||
// With click handlers:
|
||||
const items = [
|
||||
{ label: 'Home', onClick: () => console.log('Home clicked') },
|
||||
{ label: 'Settings', onClick: () => console.log('Settings clicked') },
|
||||
{ label: 'Profile' }
|
||||
];
|
||||
<Breadcrumb items={items} />
|
||||
|
||||
// External links:
|
||||
const items = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'External', href: 'https://example.com', target: '_blank' },
|
||||
{ label: 'Current' }
|
||||
];
|
||||
<Breadcrumb items={items} />
|
||||
|
||||
// Custom separator:
|
||||
<Breadcrumb items={items} separator="/" />
|
||||
<Breadcrumb items={items} separator="→" />
|
||||
<Breadcrumb items={items} separator={() => <span className="mx-2">•</span>} />
|
||||
|
||||
// Using the helper hook:
|
||||
import { useLocation } from 'react-router';
|
||||
import { useBreadcrumbItems } from './Breadcrumb';
|
||||
|
||||
function MyPage() {
|
||||
const location = useLocation();
|
||||
const items = useBreadcrumbItems(location.pathname, {
|
||||
'/': 'Dashboard',
|
||||
'/users': 'User Management',
|
||||
'/users/edit': 'Edit User'
|
||||
});
|
||||
|
||||
return <Breadcrumb items={items} />;
|
||||
}
|
||||
|
||||
// Disabled items:
|
||||
const items = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Restricted', to: '/admin', disabled: true },
|
||||
{ label: 'Current' }
|
||||
];
|
||||
<Breadcrumb items={items} />
|
||||
|
||||
// Custom styling:
|
||||
<Breadcrumb
|
||||
items={items}
|
||||
className="mb-4"
|
||||
ariaLabel="Main navigation"
|
||||
/>
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// PERFORMANCE TIPS
|
||||
// ============================================
|
||||
|
||||
/*
|
||||
PERFORMANCE OPTIMIZATION TIPS:
|
||||
|
||||
1. Use stable item arrays:
|
||||
⌠BAD: items={[{ label: 'Home', to: '/' }, ...]}
|
||||
✅ GOOD: const items = useMemo(() => [...], [deps]);
|
||||
|
||||
2. Provide keys for items when in dynamic lists:
|
||||
items.map(item => ({ ...item, key: item.id }))
|
||||
|
||||
3. Use the useBreadcrumbItems hook for automatic generation
|
||||
|
||||
4. Avoid inline functions:
|
||||
⌠BAD: onClick={() => navigate('/home')}
|
||||
✅ GOOD: const handleClick = useCallback(() => navigate('/home'), [navigate]);
|
||||
|
||||
5. Monitor performance:
|
||||
- Use React DevTools Profiler
|
||||
- Check re-render frequency
|
||||
- Verify memo is working
|
||||
|
||||
6. For large breadcrumb trails (>10 items), consider:
|
||||
- Collapsing middle items with ellipsis
|
||||
- Virtual scrolling for horizontal overflow
|
||||
- Lazy loading icons
|
||||
|
||||
7. Theme optimization:
|
||||
- Ensure useUIXTheme is properly memoized
|
||||
- Consider caching theme classes if they change frequently
|
||||
*/
|
||||
317
web/maplefile-frontend/src/components/UIX/Button/Button.jsx
Normal file
317
web/maplefile-frontend/src/components/UIX/Button/Button.jsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// File Path: web/frontend/src/components/UIX/Button/Button.jsx
|
||||
// Button Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo, useCallback, forwardRef } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Button Component - Performance Optimized
|
||||
* Versatile button with multiple variants, sizes, and states
|
||||
*
|
||||
* @param {React.ReactNode} children - Button content
|
||||
* @param {string} variant - Button style variant
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {boolean} disabled - Disabled state
|
||||
* @param {string} type - HTML button type
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} loading - Loading state
|
||||
* @param {string} loadingText - Custom loading text
|
||||
* @param {boolean} fullWidth - Full width button
|
||||
* @param {string} size - Button size: 'sm', 'md', 'lg', 'xl'
|
||||
* @param {React.ComponentType} icon - Icon component
|
||||
* @param {boolean} gradient - Apply gradient style
|
||||
* @param {string} ariaLabel - Accessibility label
|
||||
*/
|
||||
|
||||
// Static configurations
|
||||
const SIZE_CLASSES = Object.freeze({
|
||||
sm: "px-3 py-2 text-xs sm:text-sm",
|
||||
md: "px-4 py-3 text-sm sm:text-base",
|
||||
lg: "px-6 sm:px-8 py-3 sm:py-4 text-sm sm:text-base",
|
||||
xl: "px-8 py-4 text-base sm:text-lg",
|
||||
});
|
||||
|
||||
const BASE_CLASSES =
|
||||
"font-medium rounded-xl focus:outline-none transition-all duration-200 inline-flex items-center justify-center";
|
||||
const DISABLED_CLASSES = "opacity-50 cursor-not-allowed";
|
||||
const ENABLED_CLASSES = "cursor-pointer";
|
||||
|
||||
// Fallback classes if theme not available
|
||||
const FALLBACK_CLASSES = Object.freeze({
|
||||
primary:
|
||||
"bg-blue-600 hover:bg-blue-700 text-white focus:ring-2 focus:ring-blue-500",
|
||||
secondary:
|
||||
"bg-gray-200 hover:bg-gray-300 text-gray-800 focus:ring-2 focus:ring-gray-500",
|
||||
outline:
|
||||
"border-2 border-gray-300 hover:border-gray-400 text-gray-700 focus:ring-2 focus:ring-gray-500",
|
||||
success:
|
||||
"bg-green-600 hover:bg-green-700 text-white focus:ring-2 focus:ring-green-500",
|
||||
danger:
|
||||
"bg-red-600 hover:bg-red-700 text-white focus:ring-2 focus:ring-red-500",
|
||||
ghost: "hover:bg-gray-100 text-gray-700 focus:ring-2 focus:ring-gray-500",
|
||||
disabled: "bg-gray-300 text-gray-500 cursor-not-allowed",
|
||||
});
|
||||
|
||||
// Variant to theme key mapping
|
||||
const VARIANT_THEME_MAP = Object.freeze({
|
||||
primary: "button-primary",
|
||||
secondary: "button-secondary",
|
||||
outline: "button-outline",
|
||||
success: "button-success",
|
||||
danger: "button-danger",
|
||||
ghost: "button-ghost",
|
||||
disabled: "button-disabled",
|
||||
});
|
||||
|
||||
// Loading Spinner Component - Separated for better performance
|
||||
const LoadingSpinner = memo(function LoadingSpinner({ className = "w-4 h-4" }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin -ml-1 mr-2 text-current ${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = "LoadingSpinner";
|
||||
|
||||
// Main Button Component
|
||||
const Button = memo(
|
||||
forwardRef(function Button(
|
||||
{
|
||||
children,
|
||||
variant = "primary",
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = "button",
|
||||
className = "",
|
||||
loading = false,
|
||||
loadingText = "Loading...",
|
||||
fullWidth = false,
|
||||
size = "md",
|
||||
icon: Icon,
|
||||
gradient = false,
|
||||
id,
|
||||
ariaLabel,
|
||||
ariaPressed,
|
||||
ariaExpanded,
|
||||
ariaControls,
|
||||
ariaDescribedBy,
|
||||
tabIndex,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Computed disabled state
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(() => {
|
||||
// Handle gradient primary variant
|
||||
if (variant === "primary" && gradient) {
|
||||
return (
|
||||
getThemeClasses("button-gradient") ||
|
||||
"bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white"
|
||||
);
|
||||
}
|
||||
|
||||
// Get theme class for variant
|
||||
const themeKey = VARIANT_THEME_MAP[variant] || VARIANT_THEME_MAP.primary;
|
||||
return (
|
||||
getThemeClasses(themeKey) ||
|
||||
FALLBACK_CLASSES[variant] ||
|
||||
FALLBACK_CLASSES.primary
|
||||
);
|
||||
}, [variant, gradient, getThemeClasses]);
|
||||
|
||||
// Memoize complete button classes
|
||||
const buttonClasses = useMemo(() => {
|
||||
const classes = [
|
||||
BASE_CLASSES,
|
||||
SIZE_CLASSES[size] || SIZE_CLASSES.md,
|
||||
themeClasses,
|
||||
isDisabled ? DISABLED_CLASSES : ENABLED_CLASSES,
|
||||
];
|
||||
|
||||
if (fullWidth) {
|
||||
classes.push("w-full");
|
||||
}
|
||||
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}, [size, themeClasses, isDisabled, fullWidth, className]);
|
||||
|
||||
// Memoize click handler
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
if (isDisabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick && typeof onClick === "function") {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[onClick, isDisabled],
|
||||
);
|
||||
|
||||
// Memoize keyboard handler
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (isDisabled) return;
|
||||
|
||||
// Activate on Enter or Space
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === " " ||
|
||||
event.key === "Spacebar"
|
||||
) {
|
||||
// Prevent default for space to avoid page scroll
|
||||
if (event.key === " " || event.key === "Spacebar") {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Only trigger for button type (submit buttons activate on Enter naturally)
|
||||
if (type === "button") {
|
||||
handleClick(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isDisabled, type, handleClick],
|
||||
);
|
||||
|
||||
// Memoize accessibility props
|
||||
const accessibilityProps = useMemo(
|
||||
() => ({
|
||||
"aria-label":
|
||||
ariaLabel || (typeof children === "string" ? children : undefined),
|
||||
"aria-pressed": ariaPressed,
|
||||
"aria-expanded": ariaExpanded,
|
||||
"aria-controls": ariaControls,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-busy": loading,
|
||||
"aria-disabled": isDisabled,
|
||||
tabIndex: isDisabled ? -1 : (tabIndex ?? 0),
|
||||
}),
|
||||
[
|
||||
ariaLabel,
|
||||
children,
|
||||
ariaPressed,
|
||||
ariaExpanded,
|
||||
ariaControls,
|
||||
ariaDescribedBy,
|
||||
loading,
|
||||
isDisabled,
|
||||
tabIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize button content
|
||||
const ButtonContent = useMemo(() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
<span>{loadingText}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Icon && (
|
||||
<Icon className="w-4 h-4 mr-2 flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{children && <span>{children}</span>}
|
||||
</>
|
||||
);
|
||||
}, [loading, loadingText, Icon, children]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
id={id}
|
||||
type={type}
|
||||
className={buttonClasses}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
{...accessibilityProps}
|
||||
{...props}
|
||||
>
|
||||
{ButtonContent}
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.loading === nextProps.loading &&
|
||||
prevProps.loadingText === nextProps.loadingText &&
|
||||
prevProps.fullWidth === nextProps.fullWidth &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.gradient === nextProps.gradient &&
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.ariaLabel === nextProps.ariaLabel &&
|
||||
prevProps.ariaPressed === nextProps.ariaPressed &&
|
||||
prevProps.ariaExpanded === nextProps.ariaExpanded &&
|
||||
prevProps.ariaControls === nextProps.ariaControls &&
|
||||
prevProps.ariaDescribedBy === nextProps.ariaDescribedBy &&
|
||||
prevProps.tabIndex === nextProps.tabIndex
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
|
||||
// Export constants for consistency
|
||||
export const BUTTON_VARIANTS = Object.freeze({
|
||||
PRIMARY: "primary",
|
||||
SECONDARY: "secondary",
|
||||
OUTLINE: "outline",
|
||||
SUCCESS: "success",
|
||||
DANGER: "danger",
|
||||
GHOST: "ghost",
|
||||
DISABLED: "disabled",
|
||||
});
|
||||
|
||||
export const BUTTON_SIZES = Object.freeze({
|
||||
SMALL: "sm",
|
||||
MEDIUM: "md",
|
||||
LARGE: "lg",
|
||||
EXTRA_LARGE: "xl",
|
||||
});
|
||||
67
web/maplefile-frontend/src/components/UIX/Card/Card.jsx
Normal file
67
web/maplefile-frontend/src/components/UIX/Card/Card.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// File: src/components/UI/Card/Card.jsx
|
||||
// Card Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Card Component - Performance Optimized
|
||||
* Container component for grouping related content
|
||||
*
|
||||
* @param {React.ReactNode} children - Card content
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} padding - Padding size
|
||||
* @param {function} onClick - Click handler
|
||||
*/
|
||||
const Card = memo(
|
||||
function Card({ children, className = "", padding = "p-8", onClick, ...rest }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes with single retrieval
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
bg: getThemeClasses("bg-card"),
|
||||
border: getThemeClasses("card-border"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize the complete className
|
||||
const cardClasses = useMemo(() => {
|
||||
const classes = [
|
||||
themeClasses.bg,
|
||||
"rounded-xl",
|
||||
"shadow-lg",
|
||||
"border",
|
||||
themeClasses.border,
|
||||
padding,
|
||||
];
|
||||
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}, [themeClasses, padding, className]);
|
||||
|
||||
return <div className={cardClasses} onClick={onClick} {...rest}>{children}</div>;
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props change
|
||||
return (
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.padding === nextProps.padding &&
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.onClick === nextProps.onClick
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
Card.displayName = "Card";
|
||||
|
||||
// Export aliases for backward compatibility
|
||||
export const Panel = Card;
|
||||
export const Box = Card;
|
||||
|
||||
export default Card;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// File: src/components/UIX/CardSelectionGrid/CardSelectionGrid.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import { SelectionCard } from "../";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Reusable CardSelectionGrid Component - Performance Optimized Version
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - Component memoization to prevent unnecessary re-renders
|
||||
* - useMemo for expensive computations
|
||||
* - useCallback for stable function references
|
||||
* - Proper key generation for list items
|
||||
* - Optimized re-render conditions
|
||||
*/
|
||||
const CardSelectionGrid = memo(
|
||||
({
|
||||
options = [],
|
||||
layout = "4-card",
|
||||
selectedValue = null,
|
||||
onFormatSelectedLabel = (value) => value,
|
||||
isLoading = false,
|
||||
variant = "primary",
|
||||
showSelectionStatus = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize grid classes to prevent recalculation on every render
|
||||
const gridClasses = useMemo(() => {
|
||||
switch (layout) {
|
||||
case "2-card":
|
||||
return "grid grid-cols-1 md:grid-cols-2 gap-4";
|
||||
case "4-card":
|
||||
default:
|
||||
return "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4";
|
||||
}
|
||||
}, [layout]);
|
||||
|
||||
// Memoize formatted selected label to prevent recalculation
|
||||
const formattedSelectedLabel = useMemo(() => {
|
||||
if (!selectedValue) return "";
|
||||
return onFormatSelectedLabel(selectedValue);
|
||||
}, [selectedValue, onFormatSelectedLabel]);
|
||||
|
||||
// Memoize theme classes for selection status
|
||||
const selectionStatusClasses = useMemo(
|
||||
() => ({
|
||||
container: `mb-6 p-4 rounded-lg flex items-center ${getThemeClasses("success-bg")} ${getThemeClasses("success-border")} border`,
|
||||
text: `text-sm ${getThemeClasses("success-text")}`,
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize processed options to prevent recalculation on every render
|
||||
const processedOptions = useMemo(() => {
|
||||
return options.map((option, index) => ({
|
||||
...option,
|
||||
variant: option.variant || variant,
|
||||
// Generate stable unique key
|
||||
uniqueKey: option.key || option.title || `option-${option.id || index}`,
|
||||
// Generate stable ID
|
||||
uniqueId:
|
||||
option.title?.toLowerCase().replace(/\s+/g, "-") ||
|
||||
`option-${option.id || index}`,
|
||||
}));
|
||||
}, [options, variant]);
|
||||
|
||||
// Memoize the selection status component
|
||||
const SelectionStatus = useMemo(() => {
|
||||
if (!showSelectionStatus || !selectedValue) return null;
|
||||
|
||||
return (
|
||||
<div className={selectionStatusClasses.container}>
|
||||
<span className={selectionStatusClasses.text}>
|
||||
Selected: <strong>{formattedSelectedLabel}</strong>
|
||||
{isLoading && " - Saving..."}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
showSelectionStatus,
|
||||
selectedValue,
|
||||
formattedSelectedLabel,
|
||||
isLoading,
|
||||
selectionStatusClasses,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Selection Status */}
|
||||
{SelectionStatus}
|
||||
|
||||
{/* Selection Cards Grid */}
|
||||
<div className={gridClasses}>
|
||||
{processedOptions.map((option) => (
|
||||
<MemoizedSelectionCard
|
||||
key={option.uniqueKey}
|
||||
id={`selection-card-${option.uniqueId}`}
|
||||
title={option.title}
|
||||
description={option.description || ""}
|
||||
icon={option.icon}
|
||||
buttonLabel={option.buttonLabel}
|
||||
onClick={option.onClick}
|
||||
variant={option.variant}
|
||||
disabled={isLoading || option.disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function for memo
|
||||
// Only re-render if these specific props change
|
||||
return (
|
||||
prevProps.selectedValue === nextProps.selectedValue &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.layout === nextProps.layout &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.showSelectionStatus === nextProps.showSelectionStatus &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.options?.length === nextProps.options?.length &&
|
||||
// Reference comparison for options (contains icon components)
|
||||
prevProps.options === nextProps.options
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Memoize SelectionCard to prevent unnecessary re-renders
|
||||
const MemoizedSelectionCard = memo(SelectionCard, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.description === nextProps.description &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.buttonLabel === nextProps.buttonLabel &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.variant === nextProps.variant &&
|
||||
prevProps.disabled === nextProps.disabled
|
||||
);
|
||||
});
|
||||
|
||||
// Display name for debugging
|
||||
CardSelectionGrid.displayName = "CardSelectionGrid";
|
||||
MemoizedSelectionCard.displayName = "MemoizedSelectionCard";
|
||||
|
||||
export default CardSelectionGrid;
|
||||
381
web/maplefile-frontend/src/components/UIX/ChangePasswordPage.jsx
Normal file
381
web/maplefile-frontend/src/components/UIX/ChangePasswordPage.jsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// File Path: web/frontend/src/components/UIX/ChangePasswordPage.jsx
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import PageHeader from "./PageHeader/PageHeader";
|
||||
import FormCard from "./FormCard/FormCard";
|
||||
import FormSection from "./Form/FormSection";
|
||||
import FormRow from "./Form/FormRow";
|
||||
import Input from "./Input/Input";
|
||||
import Button from "./Button/Button";
|
||||
import Alert from "./Alert/Alert";
|
||||
import Modal from "./Modal/Modal";
|
||||
import { UIXThemeProvider } from "./themes/useUIXTheme.jsx";
|
||||
import Badge from "./Badge/Badge";
|
||||
import Breadcrumb from "./Breadcrumb/Breadcrumb";
|
||||
import Loading from "./Loading/Loading";
|
||||
import {
|
||||
KeyIcon,
|
||||
ArrowLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
LockClosedIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* Reusable Change Password Page Component
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.entityId - The ID of the entity (customer, user, etc.)
|
||||
* @param {Function} props.onFetchEntity - Async function to fetch entity details (entityId, onUnauthorized, forceRefresh) => Promise<entity>
|
||||
* @param {Function} props.onChangePassword - Async function to change password (passwordData, onUnauthorized) => Promise<void>
|
||||
* @param {Function} props.onUnauthorized - Callback when unauthorized
|
||||
* @param {Function} props.isAuthenticated - Function to check if user is authenticated
|
||||
* @param {Array} props.breadcrumbItems - Array of breadcrumb items
|
||||
* @param {string} props.backUrl - URL to navigate back to
|
||||
* @param {string} props.successRedirectUrl - URL to redirect after successful password change
|
||||
* @param {string} props.pageTitle - Page title (default: "Change Password")
|
||||
* @param {Function} props.getEntityName - Function to get entity display name from entity object
|
||||
* @param {Function} props.getBadges - Function to get badge components from entity object (optional)
|
||||
* @param {number} props.minPasswordLength - Minimum password length (default: 8)
|
||||
*/
|
||||
function ChangePasswordPage({
|
||||
entityId,
|
||||
onFetchEntity,
|
||||
onChangePassword,
|
||||
onUnauthorized,
|
||||
isAuthenticated,
|
||||
breadcrumbItems,
|
||||
backUrl,
|
||||
successRedirectUrl,
|
||||
pageTitle = "Change Password",
|
||||
getEntityName,
|
||||
getBadges,
|
||||
minPasswordLength = 8,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Component state
|
||||
const [entity, setEntity] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
// Form fields
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordRepeated, setPasswordRepeated] = useState("");
|
||||
|
||||
// Fetch entity details
|
||||
useEffect(() => {
|
||||
const fetchEntity = async () => {
|
||||
if (!isAuthenticated()) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
setErrors({ general: "Entity ID is required" });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
const entityData = await onFetchEntity(
|
||||
entityId,
|
||||
onUnauthorized,
|
||||
true, // forceRefresh
|
||||
);
|
||||
|
||||
setEntity(entityData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch entity:", error);
|
||||
setErrors({ general: error.message || "Failed to load information" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntity();
|
||||
}, [entityId, onFetchEntity, isAuthenticated, navigate, onUnauthorized]);
|
||||
|
||||
// Handle form validation
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!password.trim()) {
|
||||
newErrors.password = "Password is required";
|
||||
} else if (password.length < minPasswordLength) {
|
||||
newErrors.password = `Password must be at least ${minPasswordLength} characters long`;
|
||||
}
|
||||
|
||||
if (!passwordRepeated.trim()) {
|
||||
newErrors.passwordRepeated = "Password confirmation is required";
|
||||
} else if (password !== passwordRepeated) {
|
||||
newErrors.passwordRepeated = "Passwords do not match";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [password, passwordRepeated, minPasswordLength]);
|
||||
|
||||
// Handle password change
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
const passwordData = {
|
||||
password: password,
|
||||
password_repeated: passwordRepeated,
|
||||
};
|
||||
|
||||
await onChangePassword(passwordData, onUnauthorized);
|
||||
|
||||
// Success
|
||||
setSuccessMessage("Password changed successfully");
|
||||
setShowConfirmModal(false);
|
||||
|
||||
// Clear form
|
||||
setPassword("");
|
||||
setPasswordRepeated("");
|
||||
|
||||
// Show success message briefly then redirect
|
||||
setTimeout(() => {
|
||||
navigate(successRedirectUrl);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to change password:", error);
|
||||
setErrors({ general: error.message || "Failed to change password" });
|
||||
setShowConfirmModal(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [password, passwordRepeated, onChangePassword, onUnauthorized, navigate, successRedirectUrl]);
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (validateForm()) {
|
||||
setShowConfirmModal(true);
|
||||
}
|
||||
}, [validateForm]);
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
setShowConfirmModal(false);
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
// Memoize error message to prevent re-creating on every render
|
||||
const errorMessage = useMemo(() => {
|
||||
if (Object.keys(errors).length === 0) return null;
|
||||
return Object.entries(errors)
|
||||
.map(([field, message]) => (field === "general" ? message : `${field}: ${message}`))
|
||||
.join(", ");
|
||||
}, [errors]);
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbItems && <Breadcrumb items={breadcrumbItems} className="mb-6" />}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loading message="Loading information..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!entity && !isLoading && (
|
||||
<div className="space-y-6">
|
||||
<Alert type="error" message={errors.general || "Entity not found"} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(backUrl)}
|
||||
icon={ArrowLeftIcon}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{entity && !isLoading && (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
subtitle={getEntityName(entity)}
|
||||
icon={KeyIcon}
|
||||
>
|
||||
{getBadges && (
|
||||
<div className="flex items-center gap-2">
|
||||
{getBadges(entity)}
|
||||
</div>
|
||||
)}
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
<Alert
|
||||
type="success"
|
||||
message={successMessage}
|
||||
icon={CheckCircleIcon}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormCard
|
||||
title={pageTitle}
|
||||
icon={LockClosedIcon}
|
||||
description="Update the account password"
|
||||
maxWidth="4xl"
|
||||
>
|
||||
{/* Error display */}
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={errorMessage}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Warning message */}
|
||||
<Alert
|
||||
type="warning"
|
||||
icon={ExclamationTriangleIcon}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold">Warning</p>
|
||||
<p className="text-sm">
|
||||
You are about to <strong>change the password</strong> for this
|
||||
account. Please make sure you enter it correctly or the user
|
||||
will be locked out of their account.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormSection>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
placeholder={`Enter new password (minimum ${minPasswordLength} characters)`}
|
||||
icon={LockClosedIcon}
|
||||
error={errors.password}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
name="passwordRepeated"
|
||||
type="password"
|
||||
value={passwordRepeated}
|
||||
onChange={(value) => setPasswordRepeated(value)}
|
||||
placeholder="Enter password again"
|
||||
icon={LockClosedIcon}
|
||||
error={errors.passwordRepeated}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-6 pt-6 border-t border-gray-200">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(backUrl)}
|
||||
variant="outline"
|
||||
icon={ArrowLeftIcon}
|
||||
className="flex-1"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm and Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormCard>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showConfirmModal}
|
||||
onClose={handleModalClose}
|
||||
title="Confirm Password Change"
|
||||
icon={ExclamationTriangleIcon}
|
||||
iconColor="warning"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-base">
|
||||
Are you sure you want to <strong>change the password</strong> for
|
||||
this account?
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="font-semibold text-sm">
|
||||
{getEntityName(entity)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
Make sure the user can access their account with the new
|
||||
password.
|
||||
</p>
|
||||
|
||||
{/* Show any errors in the modal */}
|
||||
{errors.general && (
|
||||
<Alert type="error" message={errors.general} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModalClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleChangePassword}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePasswordPage;
|
||||
126
web/maplefile-frontend/src/components/UIX/Checkbox/Checkbox.jsx
Normal file
126
web/maplefile-frontend/src/components/UIX/Checkbox/Checkbox.jsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// File: src/components/UI/Checkbox/Checkbox.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Checkbox Component - Performance Optimized
|
||||
* Binary choice input element
|
||||
*
|
||||
* @param {string} id - Unique identifier for the checkbox element
|
||||
* @param {string} label - Checkbox label text
|
||||
* @param {boolean} checked - Whether checkbox is checked
|
||||
* @param {function} onChange - Change handler (receives the checked boolean directly, NOT the event)
|
||||
* @param {boolean} disabled - Whether checkbox is disabled
|
||||
* @param {string} className - Additional CSS classes
|
||||
*
|
||||
* IMPORTANT: The onChange prop receives the checked boolean directly, NOT the event object.
|
||||
* Correct usage: onChange={(checked) => setIsChecked(checked)}
|
||||
* Incorrect usage: onChange={(e) => setIsChecked(e.target.checked)} // This will cause errors!
|
||||
*/
|
||||
const Checkbox = memo(
|
||||
({ id, name, label, checked, onChange, disabled = false, className = "" }) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Generate a unique id for the checkbox field - memoized to prevent regeneration on every render
|
||||
const checkboxId = useMemo(() => {
|
||||
return (
|
||||
id ||
|
||||
name ||
|
||||
`checkbox-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
);
|
||||
}, [id, name]);
|
||||
|
||||
// Memoize the change handler to prevent recreation on every render
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
if (onChange) {
|
||||
onChange(e.target.checked);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Memoize theme classes to prevent recalculation on every render
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
inputBorder: getThemeClasses("input-border"),
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
buttonPrimary: getThemeClasses("button-primary"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(() => {
|
||||
const classes = ["flex", "items-center", "cursor-pointer"];
|
||||
|
||||
if (disabled) {
|
||||
classes.push("opacity-60", "cursor-not-allowed");
|
||||
}
|
||||
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}, [disabled, className]);
|
||||
|
||||
// Memoize input classes
|
||||
const inputClasses = useMemo(() => {
|
||||
return [
|
||||
"w-4",
|
||||
"h-4",
|
||||
themeClasses.inputBorder,
|
||||
"rounded",
|
||||
"focus:ring-2",
|
||||
themeClasses.inputFocusRing,
|
||||
themeClasses.buttonPrimary,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}, [themeClasses]);
|
||||
|
||||
// Memoize label classes
|
||||
const labelClasses = useMemo(() => {
|
||||
return `ml-2 text-sm ${themeClasses.textPrimary}`;
|
||||
}, [themeClasses.textPrimary]);
|
||||
|
||||
return (
|
||||
<label htmlFor={checkboxId} className={containerClasses}>
|
||||
<input
|
||||
id={checkboxId}
|
||||
name={name || checkboxId}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
aria-checked={checked}
|
||||
aria-disabled={disabled}
|
||||
/>
|
||||
{label && <span className={labelClasses}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for memo - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.label === nextProps.label &&
|
||||
prevProps.checked === nextProps.checked &&
|
||||
prevProps.onChange === nextProps.onChange &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
// Export alias for backward compatibility
|
||||
export const CheckBox = Checkbox;
|
||||
|
||||
export default Checkbox;
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
// File: src/components/UI/CheckboxGroup/CheckboxGroup.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
|
||||
/**
|
||||
* CheckboxGroup Component - Performance Optimized
|
||||
* Group of checkbox options with consistent styling
|
||||
*/
|
||||
const CheckboxGroup = memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
error,
|
||||
options = [],
|
||||
value = {},
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
size = "md",
|
||||
}) => {
|
||||
// Memoize size classes to prevent recreation
|
||||
const sizeClasses = useMemo(
|
||||
() => ({
|
||||
label: {
|
||||
sm: "text-sm",
|
||||
md: "text-base sm:text-lg",
|
||||
lg: "text-lg sm:text-xl",
|
||||
}[size],
|
||||
checkbox: {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
}[size],
|
||||
padding: {
|
||||
sm: "p-3",
|
||||
md: "p-4",
|
||||
lg: "p-5",
|
||||
}[size],
|
||||
text: {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-base",
|
||||
}[size],
|
||||
description: {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-sm",
|
||||
}[size],
|
||||
}),
|
||||
[size],
|
||||
);
|
||||
|
||||
// Memoize the change handler
|
||||
const handleChange = useCallback(
|
||||
(optionKey) => {
|
||||
if (disabled || !onChange) return;
|
||||
|
||||
const newValue = {
|
||||
...value,
|
||||
[optionKey]: !value[optionKey],
|
||||
};
|
||||
onChange(newValue);
|
||||
},
|
||||
[disabled, onChange, value],
|
||||
);
|
||||
|
||||
// Memoize label classes
|
||||
const labelClasses = useMemo(() => {
|
||||
return `block ${sizeClasses.label} font-semibold text-gray-700 mb-3 flex items-center`;
|
||||
}, [sizeClasses.label]);
|
||||
|
||||
// Memoize checkbox input classes
|
||||
const checkboxInputClasses = useMemo(() => {
|
||||
return `mt-1 ${sizeClasses.checkbox} text-red-600 bg-gray-100 border-gray-300 rounded focus:ring-red-500 transition-colors duration-200`;
|
||||
}, [sizeClasses.checkbox]);
|
||||
|
||||
// Memoize the error icon component
|
||||
const ErrorIcon = useMemo(() => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<p className="mb-3 text-sm text-red-600 flex items-center">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 14.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
// Memoize the label component
|
||||
const LabelComponent = useMemo(() => {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<label className={labelClasses}>
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
}, [label, required, labelClasses]);
|
||||
|
||||
// Memoize the description component
|
||||
const DescriptionComponent = useMemo(() => {
|
||||
if (!description) return null;
|
||||
|
||||
return <p className="text-sm text-gray-500 mb-4">{description}</p>;
|
||||
}, [description]);
|
||||
|
||||
// Memoize option rendering function
|
||||
const renderOption = useCallback(
|
||||
(option) => {
|
||||
const isChecked = !!value[option.key];
|
||||
|
||||
// Build container classes
|
||||
const containerClasses = [
|
||||
"flex",
|
||||
"items-start",
|
||||
sizeClasses.padding,
|
||||
"rounded-xl",
|
||||
"border-2",
|
||||
"cursor-pointer",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
];
|
||||
|
||||
if (isChecked) {
|
||||
containerClasses.push("bg-red-50", "border-red-500", "shadow-md");
|
||||
} else {
|
||||
containerClasses.push(
|
||||
"bg-white",
|
||||
"border-gray-200",
|
||||
"hover:border-gray-300",
|
||||
"hover:shadow-sm",
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
containerClasses.push("opacity-50", "cursor-not-allowed");
|
||||
}
|
||||
|
||||
return (
|
||||
<label key={option.key} className={containerClasses.join(" ")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleChange(option.key)}
|
||||
disabled={disabled}
|
||||
className={checkboxInputClasses}
|
||||
aria-checked={isChecked}
|
||||
aria-disabled={disabled}
|
||||
aria-describedby={
|
||||
option.description ? `${option.key}-description` : undefined
|
||||
}
|
||||
/>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center">
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={`${sizeClasses.checkbox} mr-2 text-red-500`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`${sizeClasses.text} font-medium text-gray-900`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<p
|
||||
id={`${option.key}-description`}
|
||||
className={`${sizeClasses.description} text-gray-500 mt-1`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
[value, handleChange, disabled, sizeClasses, checkboxInputClasses],
|
||||
);
|
||||
|
||||
// Memoize the options list
|
||||
const OptionsList = useMemo(() => {
|
||||
if (!options || options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="space-y-4">{options.map(renderOption)}</div>;
|
||||
}, [options, renderOption]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{LabelComponent}
|
||||
{DescriptionComponent}
|
||||
{ErrorIcon}
|
||||
{OptionsList}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for memo optimization
|
||||
// Only re-render when these specific props change
|
||||
return (
|
||||
prevProps.label === nextProps.label &&
|
||||
prevProps.description === nextProps.description &&
|
||||
prevProps.required === nextProps.required &&
|
||||
prevProps.error === nextProps.error &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.onChange === nextProps.onChange &&
|
||||
// Reference comparison for options (may contain complex data)
|
||||
prevProps.options === nextProps.options &&
|
||||
prevProps.options?.length === nextProps.options?.length &&
|
||||
// Reference comparison for value
|
||||
prevProps.value === nextProps.value
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
CheckboxGroup.displayName = "CheckboxGroup";
|
||||
|
||||
export default CheckboxGroup;
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
// File: src/components/UIX/CollectionIcon/CollectionIcon.jsx
|
||||
// Component for rendering collection icons (emoji, predefined icon, or default folder)
|
||||
import React from "react";
|
||||
import { FolderIcon, PhotoIcon } from "@heroicons/react/24/outline";
|
||||
import { ICON_MAP } from "../IconPicker/IconPicker";
|
||||
|
||||
// Size configurations for different use cases
|
||||
const SIZES = {
|
||||
xs: { icon: "h-4 w-4", emoji: "text-sm", container: "h-4 w-4" },
|
||||
sm: { icon: "h-5 w-5", emoji: "text-base", container: "h-5 w-5" },
|
||||
md: { icon: "h-6 w-6", emoji: "text-xl", container: "h-6 w-6" },
|
||||
lg: { icon: "h-8 w-8", emoji: "text-2xl", container: "h-8 w-8" },
|
||||
xl: { icon: "h-10 w-10", emoji: "text-3xl", container: "h-10 w-10" },
|
||||
"2xl": { icon: "h-12 w-12", emoji: "text-4xl", container: "h-12 w-12" },
|
||||
};
|
||||
|
||||
/**
|
||||
* CollectionIcon renders the appropriate icon for a collection
|
||||
*
|
||||
* @param {string} customIcon - The custom icon value (emoji, "icon:id", or empty for default)
|
||||
* @param {string} collectionType - "folder" or "album" (determines default icon)
|
||||
* @param {string} size - Size variant: "xs", "sm", "md", "lg", "xl", "2xl"
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} iconClassName - CSS classes specifically for the icon element
|
||||
*/
|
||||
const CollectionIcon = ({
|
||||
customIcon = "",
|
||||
collectionType = "folder",
|
||||
size = "md",
|
||||
className = "",
|
||||
iconClassName = "",
|
||||
}) => {
|
||||
const sizeConfig = SIZES[size] || SIZES.md;
|
||||
|
||||
// No custom icon - show default folder or album icon
|
||||
if (!customIcon || customIcon === "") {
|
||||
const DefaultIcon = collectionType === "album" ? PhotoIcon : FolderIcon;
|
||||
return (
|
||||
<DefaultIcon
|
||||
className={`${sizeConfig.icon} ${iconClassName} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Predefined icon (format: "icon:identifier")
|
||||
if (customIcon.startsWith("icon:")) {
|
||||
const iconId = customIcon.replace("icon:", "");
|
||||
const IconComponent = ICON_MAP[iconId];
|
||||
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<IconComponent
|
||||
className={`${sizeConfig.icon} ${iconClassName} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to default if icon ID not found
|
||||
const DefaultIcon = collectionType === "album" ? PhotoIcon : FolderIcon;
|
||||
return (
|
||||
<DefaultIcon
|
||||
className={`${sizeConfig.icon} ${iconClassName} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji character
|
||||
return (
|
||||
<span
|
||||
className={`${sizeConfig.emoji} leading-none ${className}`}
|
||||
role="img"
|
||||
aria-label="collection icon"
|
||||
>
|
||||
{customIcon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CollectionIconPreview renders the icon in a styled container (for edit forms)
|
||||
*/
|
||||
export const CollectionIconPreview = ({
|
||||
customIcon = "",
|
||||
collectionType = "folder",
|
||||
size = "xl",
|
||||
className = "",
|
||||
}) => {
|
||||
const hasCustomIcon = customIcon && customIcon !== "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-xl ${
|
||||
hasCustomIcon
|
||||
? "bg-gray-100"
|
||||
: collectionType === "album"
|
||||
? "bg-pink-100 text-pink-600"
|
||||
: "bg-blue-100 text-blue-600"
|
||||
} ${className}`}
|
||||
style={{
|
||||
width: size === "xl" ? "4rem" : size === "2xl" ? "5rem" : "3rem",
|
||||
height: size === "xl" ? "4rem" : size === "2xl" ? "5rem" : "3rem",
|
||||
}}
|
||||
>
|
||||
<CollectionIcon
|
||||
customIcon={customIcon}
|
||||
collectionType={collectionType}
|
||||
size={size}
|
||||
iconClassName={
|
||||
hasCustomIcon
|
||||
? "text-gray-700"
|
||||
: collectionType === "album"
|
||||
? "text-pink-600"
|
||||
: "text-blue-600"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionIcon;
|
||||
|
|
@ -0,0 +1,800 @@
|
|||
// File Path: src/components/UIX/CommentsView/CommentsView.jsx
|
||||
// Reusable CommentsView component for entity comment management - Performance Optimized
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
ChartBarIcon,
|
||||
UserGroupIcon,
|
||||
InformationCircleIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ArrowPathIcon,
|
||||
PlusCircleIcon,
|
||||
ClockIcon,
|
||||
ArchiveBoxIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
UserIcon,
|
||||
BriefcaseIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
UIXThemeProvider,
|
||||
useUIXTheme,
|
||||
Breadcrumb,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Alert,
|
||||
ContactLink,
|
||||
AddressDisplay,
|
||||
Tabs,
|
||||
} from "../";
|
||||
import { formatDateForDisplay } from "../../../services/Helpers/DateFormatter";
|
||||
|
||||
// Development-only logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
const log = (...args) => DEBUG && console.log(...args);
|
||||
const error = (...args) => console.error(...args); // Keep errors in production
|
||||
|
||||
// Constants
|
||||
const ACTIVE_STATUS = 1;
|
||||
const ARCHIVED_STATUS = 2;
|
||||
const MAX_COMMENT_LENGTH = 5000;
|
||||
|
||||
/**
|
||||
* Reusable CommentsView Component - Performance Optimized
|
||||
* A complete comments management view that provides consistent layout and functionality
|
||||
* for any entity that supports comments (staff, customers, orders, etc.)
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - React.memo for component memoization
|
||||
* - useCallback for all event handlers
|
||||
* - useMemo for all derived data
|
||||
* - AbortController for request cancellation
|
||||
* - Refs for lifecycle management
|
||||
* - Conditional development logging
|
||||
* - Optimized memo comparison (no JSON.stringify)
|
||||
*/
|
||||
|
||||
// Memoized Comment Item Component
|
||||
const CommentItem = memo(
|
||||
({ comment, index, getThemeClasses }) => {
|
||||
const containerClasses = useMemo(
|
||||
() =>
|
||||
`${getThemeClasses("card-header-bg")} rounded-lg border ${getThemeClasses("card-border")} p-4`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const userIconClasses = useMemo(
|
||||
() => `w-5 h-5 mr-2 ${getThemeClasses("link-primary")}`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const dateClasses = useMemo(
|
||||
() => `text-sm ${getThemeClasses("text-secondary")} flex items-center`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const contentContainerClasses = useMemo(
|
||||
() =>
|
||||
`${getThemeClasses("bg-card")} rounded-md p-4 border ${getThemeClasses("border-secondary")}`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
const contentClasses = useMemo(
|
||||
() =>
|
||||
`${getThemeClasses("text-primary")} whitespace-pre-wrap break-words text-sm sm:text-base lg:text-lg`,
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center">
|
||||
<UserIcon className={userIconClasses} />
|
||||
<strong className={getThemeClasses("link-primary")}>
|
||||
{comment.createdByUserName || "System"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className={dateClasses}>
|
||||
<ClockIcon className="w-4 h-4 mr-1" />
|
||||
{formatDateForDisplay(comment.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={contentContainerClasses}>
|
||||
<p className={contentClasses}>{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.comment.id === nextProps.comment.id &&
|
||||
prevProps.comment.content === nextProps.comment.content &&
|
||||
prevProps.comment.createdByUserName ===
|
||||
nextProps.comment.createdByUserName &&
|
||||
prevProps.comment.createdAt === nextProps.comment.createdAt &&
|
||||
prevProps.index === nextProps.index
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CommentItem.displayName = "CommentItem";
|
||||
|
||||
// Inner component that uses the theme hook - optimized for performance
|
||||
const CommentsViewInner = memo(
|
||||
function CommentsViewInner({
|
||||
entityData,
|
||||
entityId,
|
||||
entityType,
|
||||
breadcrumbItems,
|
||||
headerConfig,
|
||||
fieldSections,
|
||||
actionButtons,
|
||||
tabs,
|
||||
alerts,
|
||||
onCreateComment,
|
||||
onRefreshEntity,
|
||||
onUnauthorized,
|
||||
isLoading,
|
||||
error,
|
||||
onErrorClose,
|
||||
className,
|
||||
statusConfig,
|
||||
typeMap,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Use refs to track mounted state and abort controllers
|
||||
const isMountedRef = useRef(true);
|
||||
const alertTimerRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Component states
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isFetching, setFetching] = useState(false);
|
||||
const [isRefreshing, setRefreshing] = useState(false);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [topAlertMessage, setTopAlertMessage] = useState("");
|
||||
const [topAlertStatus, setTopAlertStatus] = useState("");
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
// Clear any pending timers
|
||||
if (alertTimerRef.current) {
|
||||
clearTimeout(alertTimerRef.current);
|
||||
alertTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
borderPrimary: getThemeClasses("border-primary"),
|
||||
textSecondary: getThemeClasses("text-secondary"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
cardHeaderBg: getThemeClasses("card-header-bg"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
inputBorder: getThemeClasses("input-border"),
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
inputBg: getThemeClasses("input-bg"),
|
||||
inputBorderError: getThemeClasses("input-border-error"),
|
||||
inputBgError: getThemeClasses("input-bg-error"),
|
||||
inputFocusRingError: getThemeClasses("input-focus-ring-error"),
|
||||
linkPrimary: getThemeClasses("link-primary"),
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
borderSecondary: getThemeClasses("border-secondary"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
textDanger: getThemeClasses("text-danger"),
|
||||
textWarning: getThemeClasses("text-warning"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Refresh handler with proper cleanup
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!isMountedRef.current || !onRefreshEntity || !entityId) return;
|
||||
|
||||
// Cancel any previous refresh
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setRefreshing(true);
|
||||
|
||||
try {
|
||||
await onRefreshEntity(entityId, onUnauthorized);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
log("Refresh cancelled");
|
||||
return;
|
||||
}
|
||||
error("Refresh error:", err);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setRefreshing(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [onRefreshEntity, entityId, onUnauthorized]);
|
||||
|
||||
// Optimized submit handler
|
||||
const onSubmitClick = useCallback(async () => {
|
||||
if (!isMountedRef.current || !content?.trim()) {
|
||||
if (!content?.trim()) {
|
||||
setErrors({ content: "Comment content is required" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.length > MAX_COMMENT_LENGTH) {
|
||||
setErrors({
|
||||
content: `Comment must be less than ${MAX_COMMENT_LENGTH} characters`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await onCreateComment(entityId, content, onUnauthorized);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setContent("");
|
||||
setTopAlertMessage("Comment created successfully");
|
||||
setTopAlertStatus("success");
|
||||
|
||||
// Refresh entity data
|
||||
if (onRefreshEntity) {
|
||||
await onRefreshEntity(entityId, onUnauthorized);
|
||||
}
|
||||
|
||||
// Clear alert after delay
|
||||
if (alertTimerRef.current) {
|
||||
clearTimeout(alertTimerRef.current);
|
||||
}
|
||||
|
||||
alertTimerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setTopAlertMessage("");
|
||||
setTopAlertStatus("");
|
||||
alertTimerRef.current = null;
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
error("Error creating comment:", err);
|
||||
setErrors(err);
|
||||
setTopAlertMessage("Failed to create comment");
|
||||
setTopAlertStatus("error");
|
||||
window.scrollTo(0, 0);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [content, onCreateComment, entityId, onUnauthorized, onRefreshEntity]);
|
||||
|
||||
// Memoize content change handler
|
||||
const handleContentChange = useCallback((e) => {
|
||||
setContent(e.target.value);
|
||||
}, []);
|
||||
|
||||
// Memoize close handlers
|
||||
const handleCloseTopAlert = useCallback(() => {
|
||||
if (alertTimerRef.current) {
|
||||
clearTimeout(alertTimerRef.current);
|
||||
alertTimerRef.current = null;
|
||||
}
|
||||
setTopAlertMessage("");
|
||||
setTopAlertStatus("");
|
||||
}, []);
|
||||
|
||||
const handleCloseErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
// Create status badge component
|
||||
const statusBadge = useMemo(() => {
|
||||
if (!entityData) return null;
|
||||
|
||||
if (entityData.isBanned) {
|
||||
return (
|
||||
<Badge variant="error" size="sm">
|
||||
<XCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
{statusConfig?.bannedLabel || "Banned"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (entityData.status === 1) {
|
||||
return (
|
||||
<Badge variant="primary" size="sm">
|
||||
<CheckCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
{statusConfig?.activeLabel || "Active"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
<ArchiveBoxIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
{statusConfig?.inactiveLabel || "Archived"}
|
||||
</Badge>
|
||||
);
|
||||
}, [entityData, statusConfig]);
|
||||
|
||||
// Memoize field sections
|
||||
const { primaryFieldSections, secondaryFieldSections, avatarSection } =
|
||||
useMemo(() => {
|
||||
const primary =
|
||||
fieldSections?.filter((section) => section.column === "primary") ||
|
||||
[];
|
||||
const secondary =
|
||||
fieldSections?.filter((section) => section.column === "secondary") ||
|
||||
[];
|
||||
const avatar = fieldSections?.find(
|
||||
(section) => section.type === "avatar",
|
||||
);
|
||||
|
||||
return {
|
||||
primaryFieldSections: primary,
|
||||
secondaryFieldSections: secondary,
|
||||
avatarSection: avatar,
|
||||
};
|
||||
}, [fieldSections]);
|
||||
|
||||
// Memoize sorted comments
|
||||
const sortedComments = useMemo(() => {
|
||||
if (!entityData?.comments) return [];
|
||||
return [...entityData.comments];
|
||||
}, [entityData?.comments]);
|
||||
|
||||
// Memoize text area classes
|
||||
const textareaClasses = useMemo(() => {
|
||||
if (errors.content) {
|
||||
return `block w-full px-4 py-3 border rounded-lg resize-y text-sm sm:text-base lg:text-lg ${themeClasses.inputBorderError} ${themeClasses.inputBgError} ${themeClasses.inputFocusRingError}`;
|
||||
}
|
||||
return `block w-full px-4 py-3 border rounded-lg resize-y text-sm sm:text-base lg:text-lg ${themeClasses.inputBg} ${themeClasses.inputBorder} ${themeClasses.inputFocusRing}`;
|
||||
}, [errors.content, themeClasses.inputBorder, themeClasses.inputFocusRing, themeClasses.inputBg, themeClasses.inputBorderError, themeClasses.inputBgError, themeClasses.inputFocusRingError]);
|
||||
|
||||
// Memoize character count classes
|
||||
const charCountClasses = useMemo(() => {
|
||||
if (content.length > MAX_COMMENT_LENGTH * 0.9) {
|
||||
return `text-sm ${themeClasses.textDanger}`;
|
||||
}
|
||||
return `text-sm ${themeClasses.textSecondary}`;
|
||||
}, [content.length, themeClasses.textSecondary, themeClasses.textDanger]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !entityData?.id) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`}
|
||||
></div>
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base ${themeClasses.textSecondary}`}
|
||||
>
|
||||
{headerConfig?.loadingText || "Loading details..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 ${className}`}
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbItems && breadcrumbItems.length > 0 && (
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
)}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{alerts?.archived && entityData && entityData.status === 2 && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={alerts.archived.message || "This item is archived"}
|
||||
icon={alerts.archived.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
{alerts?.banned && entityData && entityData.isBanned && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={alerts.banned.message || "This item is banned"}
|
||||
icon={alerts.banned.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{errors &&
|
||||
typeof errors === "object" &&
|
||||
Object.keys(errors).length > 0 &&
|
||||
!topAlertMessage && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`Error loading ${entityType} details`}
|
||||
onClose={handleCloseErrors}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content with Header */}
|
||||
<div className="shadow-sm">
|
||||
{entityData && (
|
||||
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
|
||||
{/* Header with Actions */}
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
|
||||
{headerConfig?.icon && (
|
||||
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
|
||||
)}
|
||||
{headerConfig?.title || `${entityType} - Comments`}
|
||||
</h2>
|
||||
{actionButtons && actionButtons.length > 0 && (
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{actionButtons.map((button, index) =>
|
||||
button.component ? (
|
||||
<div key={index}>{button.component}</div>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
variant={button.variant}
|
||||
onClick={button.onClick}
|
||||
disabled={button.disabled}
|
||||
icon={button.icon}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
className={`${themeClasses.bgCard} border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
|
||||
>
|
||||
{tabs && tabs.length > 0 && <Tabs tabs={tabs} mode="routing" />}
|
||||
|
||||
{/* Entity Summary Layout */}
|
||||
<div className="py-4 sm:py-6 md:py-8 lg:py-10 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col xl:flex-row gap-4 sm:gap-6 lg:gap-8 xl:gap-12 items-center xl:items-start justify-center max-w-6xl mx-auto">
|
||||
{/* Avatar Section */}
|
||||
{avatarSection && (
|
||||
<div className="flex-shrink-0 order-1 xl:order-1">
|
||||
{avatarSection.component}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Container */}
|
||||
<div className="flex-1 w-full xl:flex xl:gap-8 space-y-4 sm:space-y-6 xl:space-y-0 order-2 xl:order-2">
|
||||
{/* Primary Info Column */}
|
||||
<div className="xl:flex-1 xl:min-w-0 text-center xl:text-left">
|
||||
{primaryFieldSections.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Secondary Info Column */}
|
||||
<div className="xl:flex-1 xl:min-w-0 space-y-3 sm:space-y-4 lg:space-y-6 text-center xl:text-left">
|
||||
{secondaryFieldSections.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div
|
||||
className={`mt-8 border-t ${themeClasses.cardBorder} pt-8`}
|
||||
>
|
||||
{/* Top Alert Message */}
|
||||
{topAlertMessage && (
|
||||
<Alert
|
||||
type={
|
||||
topAlertStatus === "success" ? "success" : "error"
|
||||
}
|
||||
message={topAlertMessage}
|
||||
onClose={handleCloseTopAlert}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Comment Form */}
|
||||
{entityData &&
|
||||
entityData.status !== ARCHIVED_STATUS &&
|
||||
onCreateComment && (
|
||||
<div
|
||||
className={`${themeClasses.cardHeaderBg} rounded-lg p-6 mb-8 border ${themeClasses.cardBorder}`}
|
||||
>
|
||||
<label
|
||||
htmlFor="comment-content-textarea"
|
||||
className={`block text-sm sm:text-base lg:text-lg font-medium ${themeClasses.textPrimary} mb-3`}
|
||||
>
|
||||
Add New Comment{" "}
|
||||
<span className={themeClasses.textDanger}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="comment-content-textarea"
|
||||
name="content"
|
||||
placeholder="Write your comment here..."
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
disabled={isSubmitting}
|
||||
className={textareaClasses}
|
||||
rows="4"
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
aria-describedby="comment-content-help comment-content-error"
|
||||
aria-invalid={!!errors.content}
|
||||
/>
|
||||
{errors.content && (
|
||||
<p
|
||||
id="comment-content-error"
|
||||
className={`mt-2 text-sm ${themeClasses.textDanger}`}
|
||||
>
|
||||
{errors.content}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span
|
||||
id="comment-content-help"
|
||||
className={charCountClasses}
|
||||
>
|
||||
{content.length}/{MAX_COMMENT_LENGTH} characters
|
||||
{content.length > MAX_COMMENT_LENGTH * 0.9 &&
|
||||
content.length < MAX_COMMENT_LENGTH && (
|
||||
<span className={`${themeClasses.textWarning} ml-2`}>
|
||||
Approaching limit
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onSubmitClick}
|
||||
disabled={isSubmitting || !content.trim()}
|
||||
variant="primary"
|
||||
icon={PlusCircleIcon}
|
||||
className="mt-4"
|
||||
aria-label="Submit new comment"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-10 w-10 border-b-2 ${themeClasses.borderPrimary} mx-auto`}
|
||||
></div>
|
||||
<p className={`mt-4 ${themeClasses.textSecondary}`}>
|
||||
Refreshing comments...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : sortedComments.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h3
|
||||
className={`text-lg font-semibold ${themeClasses.textPrimary} mb-4`}
|
||||
>
|
||||
Comments for{" "}
|
||||
{entityData.name ||
|
||||
`${entityData.firstName} ${entityData.lastName}` ||
|
||||
`${entityType} #${entityData.id}`}{" "}
|
||||
({sortedComments.length})
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{sortedComments.map((comment, index) => (
|
||||
<CommentItem
|
||||
key={comment.id || `comment-${index}`}
|
||||
comment={comment}
|
||||
index={index}
|
||||
getThemeClasses={getThemeClasses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`text-center py-16 ${themeClasses.cardHeaderBg} rounded-lg`}
|
||||
>
|
||||
<ChatBubbleLeftRightIcon
|
||||
className={`w-12 h-12 ${themeClasses.textMuted} mx-auto mb-4`}
|
||||
/>
|
||||
<h3
|
||||
className={`text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
No Comments Yet
|
||||
</h3>
|
||||
<p className={themeClasses.textSecondary}>
|
||||
Be the first to add a comment about this {entityType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{!entityData && !isLoading && (
|
||||
<div className="px-4 sm:px-6 py-8 sm:py-16 text-center">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-12 sm:w-16 h-12 sm:h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
|
||||
>
|
||||
<UserIcon
|
||||
className={`w-6 sm:w-8 h-6 sm:h-8 ${themeClasses.textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-base sm:text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
{headerConfig?.notFoundTitle || `${entityType} Not Found`}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm sm:text-base ${themeClasses.textSecondary} mb-4 sm:mb-6`}
|
||||
>
|
||||
{headerConfig?.notFoundMessage ||
|
||||
`The ${entityType} you're looking for doesn't exist or you don't have permission to view it.`}
|
||||
</p>
|
||||
{headerConfig?.notFoundAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={headerConfig.notFoundAction.onClick}
|
||||
icon={headerConfig.notFoundAction.icon}
|
||||
size="sm"
|
||||
>
|
||||
{headerConfig.notFoundAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Optimized comparison - avoid expensive JSON.stringify
|
||||
// Only re-render when critical props change
|
||||
if (
|
||||
prevProps.entityId !== nextProps.entityId ||
|
||||
prevProps.entityType !== nextProps.entityType ||
|
||||
prevProps.isLoading !== nextProps.isLoading ||
|
||||
prevProps.className !== nextProps.className ||
|
||||
prevProps.error !== nextProps.error
|
||||
) {
|
||||
return false; // Props changed, re-render
|
||||
}
|
||||
|
||||
// Check entityData - compare key properties instead of deep equality
|
||||
if (prevProps.entityData !== nextProps.entityData) {
|
||||
if (!prevProps.entityData || !nextProps.entityData) return false;
|
||||
if (
|
||||
prevProps.entityData.id !== nextProps.entityData.id ||
|
||||
prevProps.entityData.status !== nextProps.entityData.status ||
|
||||
prevProps.entityData.isBanned !== nextProps.entityData.isBanned ||
|
||||
prevProps.entityData.comments?.length !== nextProps.entityData.comments?.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For arrays/objects passed from parent, use reference equality
|
||||
// Parent should memoize these to prevent unnecessary re-renders
|
||||
if (
|
||||
prevProps.breadcrumbItems !== nextProps.breadcrumbItems ||
|
||||
prevProps.headerConfig !== nextProps.headerConfig ||
|
||||
prevProps.fieldSections !== nextProps.fieldSections ||
|
||||
prevProps.actionButtons !== nextProps.actionButtons ||
|
||||
prevProps.tabs !== nextProps.tabs ||
|
||||
prevProps.alerts !== nextProps.alerts ||
|
||||
prevProps.statusConfig !== nextProps.statusConfig ||
|
||||
prevProps.typeMap !== nextProps.typeMap
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Functions should be stable via useCallback in parent
|
||||
if (
|
||||
prevProps.onCreateComment !== nextProps.onCreateComment ||
|
||||
prevProps.onRefreshEntity !== nextProps.onRefreshEntity ||
|
||||
prevProps.onUnauthorized !== nextProps.onUnauthorized ||
|
||||
prevProps.onErrorClose !== nextProps.onErrorClose
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // No changes, skip re-render
|
||||
},
|
||||
);
|
||||
|
||||
CommentsViewInner.displayName = "CommentsViewInner";
|
||||
|
||||
// Main wrapper component that provides theme context - optimized
|
||||
const CommentsView = memo(
|
||||
function CommentsView(props) {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<CommentsViewInner {...props} />
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Efficient shallow comparison for wrapper - check key props only
|
||||
const keys = Object.keys(prevProps);
|
||||
|
||||
// Quick length check
|
||||
if (keys.length !== Object.keys(nextProps).length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each prop with reference equality
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (prevProps[key] !== nextProps[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
CommentsView.displayName = "CommentsView";
|
||||
|
||||
export default CommentsView;
|
||||
export { CommentsView };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as CommentsView } from './CommentsView.jsx';
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
// File Path: src/components/UIX/ContactLink/ContactLink.jsx
|
||||
// Reusable ContactLink component for email and phone links with theme-aware styling - Performance Optimized
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
import { EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* ContactLink Component - Performance Optimized
|
||||
* Displays email or phone contact information with theme-aware styling and appropriate icons
|
||||
*
|
||||
* Features:
|
||||
* - Theme-aware link colors that adapt to blue/red themes
|
||||
* - Automatic icon selection based on contact type
|
||||
* - Responsive sizing for mobile and desktop
|
||||
* - Accessible link formatting (mailto: and tel:)
|
||||
* - Fallback display for missing contact info
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.type - Contact type: 'email' or 'phone'
|
||||
* @param {string} props.value - Contact value (email address or phone number)
|
||||
* @param {string} props.label - Optional label for the contact type
|
||||
* @param {string} props.className - Additional CSS classes
|
||||
* @param {string} props.size - Size variant: 'sm', 'md', 'lg'
|
||||
* @param {boolean} props.showIcon - Whether to show the contact type icon
|
||||
* @param {string} props.fallbackText - Text to show when no value provided
|
||||
*/
|
||||
|
||||
// Static configuration moved outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: {
|
||||
text: "text-xs sm:text-sm",
|
||||
icon: "w-3 sm:w-4 h-3 sm:h-4",
|
||||
},
|
||||
md: {
|
||||
text: "text-sm sm:text-base lg:text-lg",
|
||||
icon: "w-4 sm:w-5 h-4 sm:h-5 lg:w-6 lg:h-6",
|
||||
},
|
||||
lg: {
|
||||
text: "text-base sm:text-lg lg:text-xl",
|
||||
icon: "w-5 sm:w-6 h-5 sm:h-6 lg:w-7 lg:h-7",
|
||||
},
|
||||
};
|
||||
|
||||
// Phone number formatter function (pure function, no need to recreate)
|
||||
const formatPhoneNumber = (val) => {
|
||||
if (!val) return val;
|
||||
const cleaned = val.replace(/\D/g, "");
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
// Contact configuration (static, no need to recreate)
|
||||
const CONTACT_CONFIG = {
|
||||
email: {
|
||||
icon: EnvelopeIcon,
|
||||
hrefPrefix: "mailto:",
|
||||
formatter: (val) => val, // Email addresses don't need formatting
|
||||
},
|
||||
phone: {
|
||||
icon: PhoneIcon,
|
||||
hrefPrefix: "tel:",
|
||||
formatter: formatPhoneNumber,
|
||||
},
|
||||
};
|
||||
|
||||
const ContactLink = memo(
|
||||
({
|
||||
type = "email",
|
||||
value,
|
||||
label,
|
||||
className = "",
|
||||
size = "md",
|
||||
showIcon = true,
|
||||
fallbackText = "Not provided",
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize configuration lookups
|
||||
const config = useMemo(() => {
|
||||
return CONTACT_CONFIG[type] || CONTACT_CONFIG.email;
|
||||
}, [type]);
|
||||
|
||||
const sizes = useMemo(() => {
|
||||
return SIZE_CLASSES[size] || SIZE_CLASSES.md;
|
||||
}, [size]);
|
||||
|
||||
// Memoize the icon component
|
||||
const IconComponent = config.icon;
|
||||
|
||||
// Memoize theme classes
|
||||
const linkPrimaryClass = useMemo(() => {
|
||||
return getThemeClasses("link-primary");
|
||||
}, [getThemeClasses]);
|
||||
|
||||
// Memoize formatted value
|
||||
const formattedValue = useMemo(() => {
|
||||
if (!value || value.trim() === "") return null;
|
||||
return config.formatter(value);
|
||||
}, [value, config]);
|
||||
|
||||
// Memoize href
|
||||
const href = useMemo(() => {
|
||||
if (!value || value.trim() === "") return null;
|
||||
return `${config.hrefPrefix}${value}`;
|
||||
}, [value, config.hrefPrefix]);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(() => {
|
||||
const classes = [
|
||||
"flex",
|
||||
"items-center",
|
||||
sizes.text,
|
||||
"justify-center",
|
||||
"xl:justify-start",
|
||||
];
|
||||
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}, [sizes.text, className]);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(() => {
|
||||
const classes = [
|
||||
sizes.icon,
|
||||
"mr-2",
|
||||
"sm:mr-3",
|
||||
"text-gray-400",
|
||||
"flex-shrink-0",
|
||||
];
|
||||
|
||||
return classes.join(" ");
|
||||
}, [sizes.icon]);
|
||||
|
||||
// Memoize link classes
|
||||
const linkClasses = useMemo(() => {
|
||||
const classes = ["font-medium", "break-all", linkPrimaryClass];
|
||||
|
||||
return classes.join(" ");
|
||||
}, [linkPrimaryClass]);
|
||||
|
||||
// Handle missing value - render fallback
|
||||
if (!value || value.trim() === "") {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{showIcon && <IconComponent className={iconClasses} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-gray-500">{fallbackText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render contact link
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{showIcon && <IconComponent className={iconClasses} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
{label && <span className="text-gray-600 mr-2">{label}:</span>}
|
||||
<a
|
||||
href={href}
|
||||
className={linkClasses}
|
||||
aria-label={`${type === "email" ? "Email" : "Call"} ${formattedValue}`}
|
||||
>
|
||||
{formattedValue}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for memo - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.value === nextProps.value &&
|
||||
prevProps.label === nextProps.label &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.showIcon === nextProps.showIcon &&
|
||||
prevProps.fallbackText === nextProps.fallbackText
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
ContactLink.displayName = "ContactLink";
|
||||
|
||||
export default ContactLink;
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
// File Path: web/frontend/src/components/UIX/CreateButton/CreateButton.jsx
|
||||
// CreateButton Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* CreateButton Component - Performance Optimized
|
||||
* Green-themed button specifically for creation actions
|
||||
*
|
||||
* @param {React.ReactNode} children - Button text content
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {string} type - Button type (button, submit, reset)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} loading - Loading state
|
||||
* @param {string} loadingText - Text to show when loading
|
||||
* @param {boolean} fullWidth - Whether button should take full width
|
||||
* @param {string} size - Button size (sm, md, lg, xl)
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to PlusIcon)
|
||||
* @param {boolean} gradient - Whether to use gradient background
|
||||
*/
|
||||
|
||||
// Static size classes - moved outside to prevent recreation
|
||||
const SIZE_CLASSES = Object.freeze({
|
||||
sm: "px-3 py-2 text-xs sm:text-sm",
|
||||
md: "px-4 py-3 text-sm sm:text-base",
|
||||
lg: "px-6 sm:px-8 py-3 sm:py-4 text-sm sm:text-base",
|
||||
xl: "px-8 py-4 text-base sm:text-lg",
|
||||
});
|
||||
|
||||
// Loading Spinner Component - Separated for better performance
|
||||
const LoadingSpinner = memo(function LoadingSpinner() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = "LoadingSpinner";
|
||||
|
||||
const CreateButton = memo(
|
||||
function CreateButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = "button",
|
||||
className = "",
|
||||
loading = false,
|
||||
loadingText,
|
||||
fullWidth = false,
|
||||
size = "lg",
|
||||
icon: Icon = PlusIcon,
|
||||
gradient = false,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Computed disabled state
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
buttonCreate: getThemeClasses("button-create"),
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize gradient style
|
||||
const gradientStyle = useMemo(() => {
|
||||
if (!gradient) return {};
|
||||
|
||||
return {
|
||||
background:
|
||||
themeClasses.bgGradientSecondary ||
|
||||
"linear-gradient(to right, #059669, #10b981)",
|
||||
};
|
||||
}, [gradient, themeClasses.bgGradientSecondary]);
|
||||
|
||||
// Memoize button classes
|
||||
const buttonClasses = useMemo(() => {
|
||||
const classes = [
|
||||
SIZE_CLASSES[size] || SIZE_CLASSES.lg,
|
||||
"font-medium",
|
||||
"rounded-xl",
|
||||
"focus:outline-none",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
];
|
||||
|
||||
if (fullWidth) {
|
||||
classes.push("w-full");
|
||||
}
|
||||
|
||||
// Variant classes
|
||||
if (gradient) {
|
||||
classes.push(
|
||||
"border-transparent",
|
||||
"text-white",
|
||||
"shadow-lg",
|
||||
"hover:shadow-xl",
|
||||
themeClasses.inputFocusRing,
|
||||
"transform",
|
||||
"hover:scale-105",
|
||||
"font-bold",
|
||||
);
|
||||
} else {
|
||||
classes.push(themeClasses.buttonCreate);
|
||||
}
|
||||
|
||||
// State classes
|
||||
if (isDisabled) {
|
||||
classes.push("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
classes.push("cursor-pointer");
|
||||
}
|
||||
|
||||
// Custom className
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}, [size, fullWidth, gradient, themeClasses, isDisabled, className]);
|
||||
|
||||
// Memoize button content
|
||||
const ButtonContent = useMemo(() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
{loadingText || "Creating..."}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}, [loading, loadingText, Icon, children]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={buttonClasses}
|
||||
onClick={isDisabled ? undefined : onClick}
|
||||
disabled={isDisabled}
|
||||
style={gradientStyle}
|
||||
>
|
||||
{ButtonContent}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.loading === nextProps.loading &&
|
||||
prevProps.loadingText === nextProps.loadingText &&
|
||||
prevProps.fullWidth === nextProps.fullWidth &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.gradient === nextProps.gradient
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
CreateButton.displayName = "CreateButton";
|
||||
|
||||
export default CreateButton;
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
// File Path: web/frontend/src/components/UIX/CreateFirstButton/CreateFirstButton.jsx
|
||||
// CreateFirstButton Component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* CreateFirstButton Component - Performance Optimized
|
||||
* Green-themed button specifically for "Create First" actions in empty states
|
||||
* Larger and more prominent than regular create buttons
|
||||
*
|
||||
* @param {React.ReactNode} children - Button text content
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {string} type - Button type (button, submit, reset)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} loading - Loading state
|
||||
* @param {string} loadingText - Text to show when loading
|
||||
* @param {boolean} fullWidth - Whether button should take full width
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to PlusIcon)
|
||||
* @param {boolean} gradient - Whether to use gradient background (default: true)
|
||||
*/
|
||||
|
||||
// Static size classes for large button
|
||||
const SIZE_CLASSES = "px-8 py-4 text-base sm:text-lg";
|
||||
|
||||
// Gradient styles - static since they don't change
|
||||
const GRADIENT_STYLES = Object.freeze({
|
||||
default: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
|
||||
hover: "linear-gradient(135deg, #047857 0%, #059669 100%)",
|
||||
});
|
||||
|
||||
// Loading Spinner Component - Separated for better performance
|
||||
const LoadingSpinner = memo(function LoadingSpinner() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-5 w-5 text-current"
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = "LoadingSpinner";
|
||||
|
||||
const CreateFirstButton = memo(
|
||||
function CreateFirstButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = "button",
|
||||
className = "",
|
||||
loading = false,
|
||||
loadingText,
|
||||
fullWidth = false,
|
||||
icon: Icon = PlusIcon,
|
||||
gradient = true,
|
||||
}) {
|
||||
// Computed disabled state
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Memoize button classes
|
||||
const buttonClasses = useMemo(() => {
|
||||
const classes = [
|
||||
SIZE_CLASSES,
|
||||
"font-bold",
|
||||
"rounded-xl",
|
||||
"focus:outline-none",
|
||||
"transition-all",
|
||||
"duration-200",
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
];
|
||||
|
||||
if (fullWidth) {
|
||||
classes.push("w-full");
|
||||
}
|
||||
|
||||
// Variant classes based on gradient
|
||||
if (gradient) {
|
||||
classes.push(
|
||||
"border-transparent",
|
||||
"text-white",
|
||||
"shadow-lg",
|
||||
"hover:shadow-xl",
|
||||
"focus:ring-4",
|
||||
"focus:ring-green-500/20",
|
||||
"transform",
|
||||
"hover:scale-105",
|
||||
// Use CSS classes for hover effect instead of inline styles
|
||||
"hover:brightness-95",
|
||||
);
|
||||
} else {
|
||||
classes.push(
|
||||
"bg-green-600",
|
||||
"text-white",
|
||||
"hover:bg-green-700",
|
||||
"focus:ring-4",
|
||||
"focus:ring-green-500/20",
|
||||
"border",
|
||||
"border-transparent",
|
||||
"shadow-sm",
|
||||
"hover:shadow-md",
|
||||
);
|
||||
}
|
||||
|
||||
// State classes
|
||||
if (isDisabled) {
|
||||
classes.push("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
classes.push("cursor-pointer");
|
||||
}
|
||||
|
||||
// Custom className
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}, [fullWidth, gradient, isDisabled, className]);
|
||||
|
||||
// Memoize gradient style
|
||||
const gradientStyle = useMemo(() => {
|
||||
if (!gradient) return {};
|
||||
|
||||
return {
|
||||
background: GRADIENT_STYLES.default,
|
||||
};
|
||||
}, [gradient]);
|
||||
|
||||
// Memoize button content
|
||||
const ButtonContent = useMemo(() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
{loadingText || "Creating..."}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Icon && <Icon className="w-5 h-5 mr-2" />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}, [loading, loadingText, Icon, children]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={buttonClasses}
|
||||
onClick={isDisabled ? undefined : onClick}
|
||||
disabled={isDisabled}
|
||||
style={gradientStyle}
|
||||
>
|
||||
{ButtonContent}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function - only re-render when these props actually change
|
||||
return (
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.type === nextProps.type &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.loading === nextProps.loading &&
|
||||
prevProps.loadingText === nextProps.loadingText &&
|
||||
prevProps.fullWidth === nextProps.fullWidth &&
|
||||
prevProps.icon === nextProps.icon &&
|
||||
prevProps.gradient === nextProps.gradient
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Display name for debugging
|
||||
CreateFirstButton.displayName = "CreateFirstButton";
|
||||
|
||||
export default CreateFirstButton;
|
||||
545
web/maplefile-frontend/src/components/UIX/DataList/DataList.jsx
Normal file
545
web/maplefile-frontend/src/components/UIX/DataList/DataList.jsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
// File Path: web/frontend/src/components/UIX/DataList/DataList.jsx
|
||||
// Reusable DataList component - Performance Optimized
|
||||
|
||||
import React, { memo, useMemo, useCallback } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { SearchFilter, Alert, DetailPageIcon } from "../";
|
||||
import Button from "../Button/Button";
|
||||
import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* DataList Component - Performance Optimized
|
||||
* A complete data listing component with search, filters, table, and pagination
|
||||
*/
|
||||
|
||||
// Table Cell Component - Separated for performance
|
||||
const TableCell = memo(function TableCell({
|
||||
column,
|
||||
item,
|
||||
rowIndex,
|
||||
getThemeClasses,
|
||||
}) {
|
||||
// Memoize cell content
|
||||
const cellContent = useMemo(() => {
|
||||
// Custom render function
|
||||
if (typeof column.render === "function") {
|
||||
return column.render(
|
||||
item[column.key || column.accessor || column.field],
|
||||
item,
|
||||
rowIndex,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the value
|
||||
let value = null;
|
||||
if (column.accessor) {
|
||||
value = item[column.accessor];
|
||||
} else if (column.key) {
|
||||
value = item[column.key];
|
||||
} else if (column.field) {
|
||||
value = item[column.field];
|
||||
} else if (column.dataIndex) {
|
||||
value = item[column.dataIndex];
|
||||
}
|
||||
|
||||
// Handle special column types
|
||||
if (column.type === "link" && value) {
|
||||
const linkPath =
|
||||
typeof column.linkPath === "function"
|
||||
? column.linkPath(item)
|
||||
: column.linkPath?.replace(":id", item.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={linkPath}
|
||||
className="text-gray-900 hover:text-red-600 font-medium"
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.type === "action" && column.actionConfig) {
|
||||
const {
|
||||
label,
|
||||
linkPath,
|
||||
className: actionClassName,
|
||||
} = column.actionConfig;
|
||||
const path =
|
||||
typeof linkPath === "function"
|
||||
? linkPath(item)
|
||||
: linkPath?.replace(":id", item.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={path}
|
||||
className={
|
||||
actionClassName ||
|
||||
`inline-flex items-center px-4 py-2 text-base font-bold ${getThemeClasses("view-button")} rounded-lg transition-all duration-200`
|
||||
}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
<ChevronRightIcon className="w-5 h-5 ml-2" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Return the value or placeholder
|
||||
return value !== null && value !== undefined ? (
|
||||
<span className="text-gray-900">{value}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">—</span>
|
||||
);
|
||||
}, [column, item, rowIndex, getThemeClasses]);
|
||||
|
||||
// Memoize cell alignment classes
|
||||
const alignmentClass = useMemo(() => {
|
||||
if (column.align === "center") return "text-center";
|
||||
if (column.align === "right") return "text-right";
|
||||
return "text-left";
|
||||
}, [column.align]);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={`px-6 py-4 whitespace-nowrap text-lg align-middle ${alignmentClass}`}
|
||||
>
|
||||
{cellContent}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
// Table Row Component - Separated for performance
|
||||
const TableRow = memo(function TableRow({
|
||||
item,
|
||||
columns,
|
||||
rowIndex,
|
||||
getThemeClasses,
|
||||
}) {
|
||||
const uniqueKey = useMemo(
|
||||
() =>
|
||||
item.id !== null && item.id !== undefined
|
||||
? `item-${item.id}`
|
||||
: `row-${rowIndex}`,
|
||||
[item.id, rowIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`${getThemeClasses("table-row-hover")} transition-all duration-200 border-b border-gray-100`}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<TableCell
|
||||
key={colIndex}
|
||||
column={column}
|
||||
item={item}
|
||||
rowIndex={rowIndex}
|
||||
getThemeClasses={getThemeClasses}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
// Main DataList Component
|
||||
const DataList = memo(
|
||||
function DataList({
|
||||
// Data props
|
||||
data = [],
|
||||
columns = [],
|
||||
isLoading = false,
|
||||
errors = {},
|
||||
successMessage = "",
|
||||
onSuccessMessageClose = () => {},
|
||||
|
||||
// Search filter props
|
||||
searchFilter = {},
|
||||
|
||||
// Pagination props
|
||||
pagination = {},
|
||||
|
||||
// Empty state props
|
||||
emptyState = {},
|
||||
|
||||
// Header props
|
||||
header = {},
|
||||
|
||||
// Style props
|
||||
className = "",
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
borderPrimary: getThemeClasses("border-primary"),
|
||||
bgGradientPrimary: getThemeClasses("bg-gradient-primary"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
tableHeaderBg: getThemeClasses("table-header-bg"),
|
||||
tableRowHover: getThemeClasses("table-row-hover"),
|
||||
viewButton: getThemeClasses("view-button"),
|
||||
badgeSecondary: getThemeClasses("badge-secondary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Extract and memoize search filter props
|
||||
const searchFilterProps = useMemo(
|
||||
() => {
|
||||
const props = {
|
||||
searchTerm: searchFilter.searchTerm || "",
|
||||
tempSearchTerm: searchFilter.tempSearchTerm || "",
|
||||
onSearchTermChange: searchFilter.onSearchTermChange || (() => {}),
|
||||
onSearch: searchFilter.onSearch || (() => {}),
|
||||
searchPlaceholder: searchFilter.searchPlaceholder || "Search...",
|
||||
statusOptions: searchFilter.statusOptions || [],
|
||||
statusFilter: searchFilter.statusFilter || "",
|
||||
onStatusFilterChange: searchFilter.onStatusFilterChange || (() => {}),
|
||||
typeOptions: searchFilter.typeOptions,
|
||||
typeFilter: searchFilter.typeFilter || "",
|
||||
onTypeFilterChange: searchFilter.onTypeFilterChange || (() => {}),
|
||||
typeFilterLabel: searchFilter.typeFilterLabel || "Type",
|
||||
sortOptions: searchFilter.sortOptions || [],
|
||||
sortValue: searchFilter.sortValue || "",
|
||||
onSortChange: searchFilter.onSortChange || (() => {}),
|
||||
pageSizeOptions: searchFilter.pageSizeOptions || [10, 25, 50, 100],
|
||||
pageSize: searchFilter.pageSize || 25,
|
||||
onPageSizeChange: searchFilter.onPageSizeChange || (() => {}),
|
||||
onClearFilters: searchFilter.onClearFilters || (() => {}),
|
||||
onRefresh: searchFilter.onRefresh || (() => {}),
|
||||
};
|
||||
return props;
|
||||
},
|
||||
[searchFilter],
|
||||
);
|
||||
|
||||
// Extract and memoize pagination props
|
||||
const paginationProps = useMemo(
|
||||
() => ({
|
||||
currentPage: pagination.currentPage || 1,
|
||||
totalCount: pagination.totalCount || 0,
|
||||
hasNextPage: pagination.hasNextPage || false,
|
||||
onPageChange: pagination.onPageChange || (() => {}),
|
||||
}),
|
||||
[pagination],
|
||||
);
|
||||
|
||||
// Extract and memoize empty state props
|
||||
const emptyStateProps = useMemo(
|
||||
() => ({
|
||||
icon: emptyState.icon || PlusIcon,
|
||||
title: emptyState.title || "No Items Found",
|
||||
description:
|
||||
emptyState.description || "No items have been created yet.",
|
||||
actionLabel: emptyState.actionLabel || "Create First Item",
|
||||
onActionClick: emptyState.onActionClick || (() => {}),
|
||||
showAction:
|
||||
emptyState.showAction !== undefined ? emptyState.showAction : true,
|
||||
isCreateAction: emptyState.isCreateAction || false,
|
||||
}),
|
||||
[emptyState],
|
||||
);
|
||||
|
||||
// Extract and memoize header props
|
||||
const headerProps = useMemo(
|
||||
() => ({
|
||||
icon: header.icon,
|
||||
title: header.title,
|
||||
actions: header.actions || [],
|
||||
showHeader: header.showHeader || false,
|
||||
}),
|
||||
[header],
|
||||
);
|
||||
|
||||
// Memoize pagination handlers
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
paginationProps.onPageChange(paginationProps.currentPage - 1);
|
||||
}, [paginationProps]);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
paginationProps.onPageChange(paginationProps.currentPage + 1);
|
||||
}, [paginationProps]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading && (!data || data.length === 0)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`}
|
||||
></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen ${themeClasses.bgGradientPrimary} ${className}`}
|
||||
>
|
||||
{/* Decorative background elements */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-yellow-200 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 lg:py-12">
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<Alert
|
||||
type="success"
|
||||
message={successMessage}
|
||||
onClose={onSuccessMessageClose}
|
||||
className="mb-6 sm:mb-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Messages */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={Object.values(errors)[0]}
|
||||
onClose={() => {}}
|
||||
className="mb-6 sm:mb-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Layout */}
|
||||
<div className="w-full">
|
||||
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 hover:shadow-2xl transition-shadow duration-300">
|
||||
{/* Header Section */}
|
||||
{headerProps.showHeader && (
|
||||
<div className="px-6 sm:px-8 py-6 border-b border-gray-200">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-start lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
{headerProps.icon && (
|
||||
<div
|
||||
className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${themeClasses.bgGradientSecondary}`}
|
||||
>
|
||||
<headerProps.icon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center lg:text-left">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-800 leading-tight">
|
||||
{headerProps.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-3">
|
||||
{headerProps.actions.map((action, index) => (
|
||||
<div key={index}>{action}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Filter Component */}
|
||||
<SearchFilter {...searchFilterProps} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary}`}
|
||||
></div>
|
||||
<span className="ml-3 text-gray-600">Loading...</span>
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-full divide-y divide-gray-200">
|
||||
<thead className={themeClasses.tableHeaderBg}>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={`px-6 py-4 text-base font-bold text-white uppercase tracking-wider ${
|
||||
column.align === "center"
|
||||
? "text-center"
|
||||
: column.align === "right"
|
||||
? "text-right"
|
||||
: "text-left"
|
||||
}`}
|
||||
>
|
||||
{column.header ||
|
||||
column.label ||
|
||||
column.title ||
|
||||
""}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data.map((item, rowIndex) => (
|
||||
<TableRow
|
||||
key={
|
||||
item.id !== null && item.id !== undefined
|
||||
? `item-${item.id}`
|
||||
: `row-${rowIndex}`
|
||||
}
|
||||
item={item}
|
||||
columns={columns}
|
||||
rowIndex={rowIndex}
|
||||
getThemeClasses={getThemeClasses}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{paginationProps.totalCount > searchFilterProps.pageSize && (
|
||||
<div
|
||||
className={`px-6 py-4 flex items-center justify-between border-t border-gray-200 ${themeClasses.bgGradientPrimary}`}
|
||||
>
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={handlePreviousPage}
|
||||
disabled={paginationProps.currentPage === 1}
|
||||
className="relative inline-flex items-center px-5 py-3 border border-gray-200 text-sm font-medium rounded-xl text-gray-700 bg-white hover:bg-gray-50 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!paginationProps.hasNextPage}
|
||||
className="ml-3 relative inline-flex items-center px-5 py-3 border border-gray-200 text-sm font-medium rounded-xl text-gray-700 bg-white hover:bg-gray-50 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
<span className="font-medium">{data.length}</span>{" "}
|
||||
results
|
||||
{paginationProps.totalCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
of{" "}
|
||||
<span className="font-medium">
|
||||
{paginationProps.totalCount}
|
||||
</span>{" "}
|
||||
total
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={handlePreviousPage}
|
||||
disabled={paginationProps.currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-3 rounded-l-xl border border-gray-200 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span
|
||||
className={`relative inline-flex items-center px-5 py-3 border-t border-b border-gray-200 ${themeClasses.badgeSecondary} text-sm font-medium`}
|
||||
>
|
||||
Page {paginationProps.currentPage}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!paginationProps.hasNextPage}
|
||||
className="relative inline-flex items-center px-4 py-3 rounded-r-xl border border-gray-200 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-fit mb-4">
|
||||
<DetailPageIcon icon={emptyStateProps.icon} size="xl" />
|
||||
</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-gray-900">
|
||||
{emptyStateProps.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{emptyStateProps.description}
|
||||
</p>
|
||||
{emptyStateProps.showAction &&
|
||||
emptyStateProps.onActionClick && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant={
|
||||
emptyStateProps.isCreateAction
|
||||
? "success"
|
||||
: "primary"
|
||||
}
|
||||
size="lg"
|
||||
onClick={emptyStateProps.onActionClick}
|
||||
icon={PlusIcon}
|
||||
>
|
||||
{emptyStateProps.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison - only re-render when important props change
|
||||
// Note: We avoid JSON.stringify for props that may contain React components,
|
||||
// functions, or circular references. Use shallow equality instead.
|
||||
|
||||
// Simple value comparisons
|
||||
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||
if (prevProps.className !== nextProps.className) return false;
|
||||
if (prevProps.successMessage !== nextProps.successMessage) return false;
|
||||
|
||||
// Data comparison (length and reference)
|
||||
if (prevProps.data?.length !== nextProps.data?.length) return false;
|
||||
if (prevProps.data !== nextProps.data) return false;
|
||||
|
||||
// Columns comparison (length and reference - columns contain render functions)
|
||||
if (prevProps.columns?.length !== nextProps.columns?.length) return false;
|
||||
if (prevProps.columns !== nextProps.columns) return false;
|
||||
|
||||
// Errors comparison (shallow)
|
||||
if (prevProps.errors !== nextProps.errors) return false;
|
||||
|
||||
// SearchFilter comparison (reference only - contains functions)
|
||||
if (prevProps.searchFilter !== nextProps.searchFilter) return false;
|
||||
|
||||
// Pagination comparison (reference only - contains functions)
|
||||
if (prevProps.pagination !== nextProps.pagination) return false;
|
||||
|
||||
// EmptyState comparison (reference only - contains icon components)
|
||||
if (prevProps.emptyState !== nextProps.emptyState) return false;
|
||||
|
||||
// Header comparison (reference only - may contain components)
|
||||
if (prevProps.header !== nextProps.header) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
DataList.displayName = "DataList";
|
||||
|
||||
export default DataList;
|
||||
405
web/maplefile-frontend/src/components/UIX/DataList/README.md
Normal file
405
web/maplefile-frontend/src/components/UIX/DataList/README.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# DataList Component
|
||||
|
||||
A reusable data listing component based on the SkillSet list page design with search, filters, table display, and pagination. Features wider layout (max-w-5xl) and uses existing UIX components.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Complete data listing solution** - Search, filters, table, pagination all in one
|
||||
- ✅ **Based on SkillSet design** - Matches the proven SkillSet list page styling
|
||||
- ✅ **Wider layout** - Uses max-w-5xl for larger data tables
|
||||
- ✅ **UIX component integration** - Uses SearchFilter, Button, Alert, and other UIX components
|
||||
- ✅ **Flexible column configuration** - Supports links, actions, custom rendering
|
||||
- ✅ **Responsive design** - Works on all screen sizes
|
||||
- ✅ **Loading and error states** - Built-in loading spinners and error handling
|
||||
- ✅ **Empty state support** - Customizable empty state with actions
|
||||
- ✅ **Style guide compliant** - Red gradient headers and consistent styling
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```jsx
|
||||
import { DataList } from "../../../components/UIX";
|
||||
|
||||
function MyListPage() {
|
||||
const columns = [
|
||||
{
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
type: "link",
|
||||
linkPath: "/admin/items/:id/detail"
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
render: (value) => <StatusBadge status={value} />
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
align: "right",
|
||||
type: "action",
|
||||
actionConfig: {
|
||||
label: "View",
|
||||
linkPath: "/admin/items/:id/detail"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DataList
|
||||
data={items}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
searchFilter={{
|
||||
searchTerm,
|
||||
tempSearchTerm,
|
||||
onSearchTermChange: setTempSearchTerm,
|
||||
onSearch: handleSearch,
|
||||
searchPlaceholder: "Search items...",
|
||||
statusOptions: [
|
||||
{ value: "", label: "All Statuses" },
|
||||
{ value: "1", label: "Active" },
|
||||
{ value: "2", label: "Inactive" }
|
||||
],
|
||||
statusFilter,
|
||||
onStatusFilterChange: setStatusFilter,
|
||||
// ... other search filter props
|
||||
}}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
onPageChange: setCurrentPage
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### Core Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `data` | Array | `[]` | Array of data items to display |
|
||||
| `columns` | Array | `[]` | Column configuration array |
|
||||
| `isLoading` | boolean | `false` | Loading state |
|
||||
| `errors` | Object | `{}` | Error state object |
|
||||
| `successMessage` | string | `""` | Success message to display |
|
||||
| `onSuccessMessageClose` | function | `() => {}` | Success message close handler |
|
||||
| `className` | string | `""` | Additional CSS classes |
|
||||
|
||||
### Search Filter Props (searchFilter object)
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `searchTerm` | string | `""` | Current search term |
|
||||
| `tempSearchTerm` | string | `""` | Temporary search term (before search) |
|
||||
| `onSearchTermChange` | function | `() => {}` | Search term change handler |
|
||||
| `onSearch` | function | `() => {}` | Search execution handler |
|
||||
| `searchPlaceholder` | string | `"Search..."` | Search input placeholder |
|
||||
| `statusOptions` | Array | `[]` | Status filter options |
|
||||
| `statusFilter` | string | `""` | Current status filter |
|
||||
| `onStatusFilterChange` | function | `() => {}` | Status filter change handler |
|
||||
| `sortOptions` | Array | `[]` | Sort options |
|
||||
| `sortValue` | string | `""` | Current sort value |
|
||||
| `onSortChange` | function | `() => {}` | Sort change handler |
|
||||
| `pageSizeOptions` | Array | `[10, 25, 50, 100]` | Page size options |
|
||||
| `pageSize` | number | `25` | Current page size |
|
||||
| `onPageSizeChange` | function | `() => {}` | Page size change handler |
|
||||
| `onClearFilters` | function | `() => {}` | Clear filters handler |
|
||||
| `onRefresh` | function | `() => {}` | Refresh data handler |
|
||||
|
||||
### Pagination Props (pagination object)
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `currentPage` | number | `1` | Current page number |
|
||||
| `totalCount` | number | `0` | Total number of items |
|
||||
| `hasNextPage` | boolean | `false` | Whether there's a next page |
|
||||
| `onPageChange` | function | `() => {}` | Page change handler |
|
||||
|
||||
### Empty State Props (emptyState object)
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `icon` | Component | `PlusIcon` | Icon component for empty state |
|
||||
| `title` | string | `"No Items Found"` | Empty state title |
|
||||
| `description` | string | `"No items have been created yet."` | Empty state description |
|
||||
| `actionLabel` | string | `"Create First Item"` | Action button label |
|
||||
| `onActionClick` | function | `() => {}` | Action button click handler |
|
||||
| `showAction` | boolean | `true` | Whether to show action button |
|
||||
|
||||
## Column Configuration
|
||||
|
||||
### Basic Column
|
||||
|
||||
```jsx
|
||||
{
|
||||
header: "Column Title",
|
||||
accessor: "dataProperty", // or key, field, dataIndex
|
||||
align: "left" // "left", "center", "right"
|
||||
}
|
||||
```
|
||||
|
||||
### Link Column
|
||||
|
||||
```jsx
|
||||
{
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
type: "link",
|
||||
linkPath: "/admin/items/:id/detail" // :id will be replaced with item.id
|
||||
}
|
||||
```
|
||||
|
||||
### Action Column
|
||||
|
||||
```jsx
|
||||
{
|
||||
header: "Actions",
|
||||
align: "right",
|
||||
type: "action",
|
||||
actionConfig: {
|
||||
label: "View",
|
||||
linkPath: "/admin/items/:id/detail",
|
||||
className: "custom-action-styles" // optional
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Render Column
|
||||
|
||||
```jsx
|
||||
{
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
render: (value, item, rowIndex) => {
|
||||
return <StatusBadge status={value} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { DataList, Breadcrumb, PageHeader } from "../../../components/UIX";
|
||||
import { useItemManager } from "../../../services/Services";
|
||||
|
||||
function ItemListPage() {
|
||||
const itemManager = useItemManager();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// State
|
||||
const [items, setItems] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
// Search and filter state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [tempSearchTerm, setTempSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [sortBy, setSortBy] = useState("name");
|
||||
const [sortOrder, setSortOrder] = useState("ASC");
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
// Column configuration
|
||||
const columns = [
|
||||
{
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
type: "link",
|
||||
linkPath: "/admin/items/:id/detail"
|
||||
},
|
||||
{
|
||||
header: "Category",
|
||||
accessor: "category"
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
render: (status) => (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
status === 1 ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}>
|
||||
{status === 1 ? "Active" : "Inactive"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
align: "right",
|
||||
type: "action",
|
||||
actionConfig: {
|
||||
label: "View",
|
||||
linkPath: "/admin/items/:id/detail"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Search filter configuration
|
||||
const searchFilterConfig = {
|
||||
searchTerm,
|
||||
tempSearchTerm,
|
||||
onSearchTermChange: setTempSearchTerm,
|
||||
onSearch: () => {
|
||||
setSearchTerm(tempSearchTerm);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
searchPlaceholder: "Search by name or category...",
|
||||
statusOptions: [
|
||||
{ value: "", label: "All Statuses" },
|
||||
{ value: "1", label: "Active" },
|
||||
{ value: "2", label: "Inactive" }
|
||||
],
|
||||
statusFilter,
|
||||
onStatusFilterChange: (value) => {
|
||||
setStatusFilter(value);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
sortOptions: [
|
||||
{ value: "name,ASC", label: "Name (A-Z)" },
|
||||
{ value: "name,DESC", label: "Name (Z-A)" },
|
||||
{ value: "category,ASC", label: "Category (A-Z)" },
|
||||
{ value: "created_at,DESC", label: "Created Date (Newest)" }
|
||||
],
|
||||
sortValue: `${sortBy},${sortOrder}`,
|
||||
onSortChange: (value) => {
|
||||
const [field, order] = value.split(",");
|
||||
setSortBy(field);
|
||||
setSortOrder(order);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
pageSize,
|
||||
onPageSizeChange: (value) => {
|
||||
setPageSize(value);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
onClearFilters: () => {
|
||||
setTempSearchTerm("");
|
||||
setSearchTerm("");
|
||||
setStatusFilter("");
|
||||
setSortBy("name");
|
||||
setSortOrder("ASC");
|
||||
setCurrentPage(1);
|
||||
},
|
||||
onRefresh: () => fetchItems({ forceRefresh: true })
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
const paginationConfig = {
|
||||
currentPage,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
onPageChange: setCurrentPage
|
||||
};
|
||||
|
||||
// Empty state configuration
|
||||
const emptyStateConfig = {
|
||||
icon: PlusIcon,
|
||||
title: "No Items Found",
|
||||
description: "No items have been created yet.",
|
||||
actionLabel: "Create First Item",
|
||||
onActionClick: () => navigate("/admin/items/create")
|
||||
};
|
||||
|
||||
// Fetch data function
|
||||
const fetchItems = async (params = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
const response = await itemManager.getItems({
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
search: searchTerm,
|
||||
status: statusFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...params
|
||||
});
|
||||
|
||||
setItems(response.results || []);
|
||||
setTotalCount(response.count || 0);
|
||||
setHasNextPage(response.hasNextPage || false);
|
||||
} catch (error) {
|
||||
setErrors({ fetch: error.message });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch data on dependency changes
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [currentPage, pageSize, searchTerm, statusFilter, sortBy, sortOrder]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Dashboard', to: '/admin/dashboard' },
|
||||
{ label: 'Items', isActive: true }
|
||||
]} />
|
||||
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="Items"
|
||||
subtitle="Manage your items"
|
||||
actions={[
|
||||
<Button
|
||||
key="create"
|
||||
variant="primary"
|
||||
onClick={() => navigate("/admin/items/create")}
|
||||
icon={PlusIcon}
|
||||
>
|
||||
New Item
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Data List */}
|
||||
<DataList
|
||||
data={items}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
errors={errors}
|
||||
successMessage={successMessage}
|
||||
onSuccessMessageClose={() => setSuccessMessage("")}
|
||||
searchFilter={searchFilterConfig}
|
||||
pagination={paginationConfig}
|
||||
emptyState={emptyStateConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemListPage;
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
1. **Admin Settings Pages** - All settings list pages (Skills, Certifications, etc.)
|
||||
2. **Staff Management** - Employee lists, role assignments
|
||||
3. **Data Management** - Any paginated data listing with search and filters
|
||||
4. **Report Views** - Displaying tabular report data
|
||||
5. **Content Management** - Managing any collection of items
|
||||
|
||||
## Design Features
|
||||
|
||||
- **Wider Layout**: Uses `max-w-5xl` instead of the typical `max-w-4xl`
|
||||
- **Red Gradient Headers**: Consistent with app theme (#8a1622 to #dc2626)
|
||||
- **Animated Background**: Subtle blob animations matching app style
|
||||
- **Hover Effects**: Row highlighting and smooth transitions
|
||||
- **Responsive Pagination**: Different layouts for mobile vs desktop
|
||||
- **Loading States**: Integrated loading spinners
|
||||
- **Empty States**: Customizable empty state with call-to-action
|
||||
|
||||
This component provides a complete, reusable solution for any data listing needs in the application while maintaining consistency with the established design patterns.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// File: src/components/UIX/DataList/index.jsx
|
||||
export { default } from "./DataList";
|
||||
239
web/maplefile-frontend/src/components/UIX/Date/Date.jsx
Normal file
239
web/maplefile-frontend/src/components/UIX/Date/Date.jsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// File: monorepo/web/frontend/src/components/UI/Date/Date.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Move constants outside component to prevent recreation
|
||||
const ZERO_DATE_PATTERNS = [
|
||||
"0001-01-01", // Go zero date
|
||||
"1970-01-01T00:00:00Z", // Unix epoch (sometimes used as null)
|
||||
"0000-00-00", // MySQL zero date
|
||||
"1900-01-01", // SQL Server min date (sometimes used as null)
|
||||
];
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "px-3 py-2 text-sm",
|
||||
md: "px-4 py-3 text-base",
|
||||
lg: "px-5 py-4 text-base sm:text-lg",
|
||||
};
|
||||
|
||||
const LABEL_SIZE_CLASSES = {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-base sm:text-lg",
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a date is considered "zero" or invalid
|
||||
* Handles various zero date formats from different backends
|
||||
*/
|
||||
const isZeroDate = (dateValue) => {
|
||||
if (!dateValue) return true;
|
||||
|
||||
const dateStr = String(dateValue);
|
||||
return ZERO_DATE_PATTERNS.some((pattern) => dateStr.startsWith(pattern));
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date value for HTML date input
|
||||
* Returns empty string for zero/invalid dates
|
||||
*/
|
||||
const formatDateForInput = (dateValue) => {
|
||||
if (!dateValue || isZeroDate(dateValue)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
let date;
|
||||
|
||||
if (typeof dateValue === "string") {
|
||||
// Check if it's already in YYYY-MM-DD format
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
|
||||
return dateValue;
|
||||
}
|
||||
date = new Date(dateValue);
|
||||
} else if (dateValue instanceof Date) {
|
||||
date = dateValue;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-DD for HTML date input
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced DateInput Component with Modern UIX Styling
|
||||
* Date input field with validation and zero-date handling, matching Input component styling
|
||||
*
|
||||
* @param {string} label - Input label
|
||||
* @param {string} value - Date value (handles various formats including ISO strings)
|
||||
* @param {function} onChange - Change handler (receives formatted date string)
|
||||
* @param {string} error - Error message
|
||||
* @param {boolean} disabled - Whether input is disabled
|
||||
* @param {boolean} required - Whether input is required
|
||||
* @param {string} min - Minimum date allowed
|
||||
* @param {string} max - Maximum date allowed
|
||||
* @param {string} helperText - Helper text below input
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} placeholder - Placeholder text
|
||||
* @param {string} size - Size variant (sm, md, lg)
|
||||
* @param {React.Component} icon - Optional icon component
|
||||
*/
|
||||
const DateInput = memo(
|
||||
({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
min,
|
||||
max,
|
||||
helperText,
|
||||
className = "",
|
||||
placeholder = "Select a date",
|
||||
size = "lg",
|
||||
icon: Icon,
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize formatted value to prevent unnecessary recalculations
|
||||
const formattedValue = useMemo(() => formatDateForInput(value), [value]);
|
||||
|
||||
// Memoize change handler to prevent recreation on every render
|
||||
const handleDateChange = useCallback(
|
||||
(e) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Memoize theme classes to prevent excessive theme function calls
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
textDanger: getThemeClasses("text-danger"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
placeholderMuted: getThemeClasses("placeholder-muted"),
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
inputBorder: getThemeClasses("input-border"),
|
||||
inputBorderError: getThemeClasses("input-border-error"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize size classes
|
||||
const sizeClass = useMemo(() => SIZE_CLASSES[size], [size]);
|
||||
const labelSizeClass = useMemo(() => LABEL_SIZE_CLASSES[size], [size]);
|
||||
|
||||
// Memoize input classes to prevent string concatenation on every render
|
||||
const inputClasses = useMemo(() => {
|
||||
return `
|
||||
w-full
|
||||
${sizeClass}
|
||||
${Icon ? "pl-10" : "pl-5"}
|
||||
border-2 rounded-xl shadow-sm
|
||||
transition-all duration-200
|
||||
${themeClasses.placeholderMuted}
|
||||
focus:outline-none ${themeClasses.inputFocusRing}
|
||||
${error ? themeClasses.inputBorderError : themeClasses.inputBorder}
|
||||
${disabled ? `${themeClasses.bgDisabled} cursor-not-allowed` : themeClasses.bgCard}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}, [sizeClass, Icon, themeClasses, error, disabled]);
|
||||
|
||||
// Memoize label classes
|
||||
const labelClasses = useMemo(
|
||||
() =>
|
||||
`block ${labelSizeClass} font-semibold ${themeClasses.textPrimary} mb-3 flex items-center`,
|
||||
[labelSizeClass, themeClasses.textPrimary],
|
||||
);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(
|
||||
() => `h-5 w-5 ${themeClasses.textMuted}`,
|
||||
[themeClasses.textMuted],
|
||||
);
|
||||
|
||||
// Memoize helper text classes
|
||||
const helperTextClasses = useMemo(
|
||||
() => `mt-1 text-xs ${themeClasses.textMuted}`,
|
||||
[themeClasses.textMuted],
|
||||
);
|
||||
|
||||
// Memoize error classes (without animation to prevent reflow)
|
||||
const errorClasses = useMemo(
|
||||
() => `mt-1 text-sm ${themeClasses.textDanger} flex items-center`,
|
||||
[themeClasses.textDanger],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className={`${themeClasses.textDanger} ml-1`}>*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon className={iconClasses} />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="date"
|
||||
value={formattedValue}
|
||||
onChange={handleDateChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
placeholder={placeholder}
|
||||
className={inputClasses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={helperTextClasses}>{helperText}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className={errorClasses}>
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1 flex-shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DateInput.displayName = "DateInput";
|
||||
|
||||
// Export with multiple names for flexibility
|
||||
export default DateInput;
|
||||
export { DateInput as Date };
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
// File: src/components/UIX/DatePicker/DatePicker.jsx
|
||||
// Enhanced DatePicker with custom calendar dropdown - Performance Optimized
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Move all constants outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: "px-3 py-2 text-sm",
|
||||
md: "px-4 py-3 text-base",
|
||||
lg: "px-5 py-4 text-base sm:text-lg",
|
||||
};
|
||||
|
||||
const LABEL_SIZE_CLASSES = {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-base sm:text-lg",
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
const DAY_HEADERS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Static helper functions outside component
|
||||
const formatDisplayDate = (date) => {
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatInputDate = (date) => {
|
||||
if (!date) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const isToday = (date) => {
|
||||
if (!date) return false;
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
const isSameDate = (date1, date2) => {
|
||||
if (!date1 || !date2) return false;
|
||||
return date1.toDateString() === date2.toDateString();
|
||||
};
|
||||
|
||||
const getCalendarDays = (currentMonth) => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
const days = [];
|
||||
|
||||
// Add empty cells for days before the first day of the month
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(new Date(year, month, day));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced DatePicker Component with Custom Calendar Dropdown
|
||||
* Provides a beautiful, customizable date picker that matches our UIX design system
|
||||
*/
|
||||
const DatePicker = memo(
|
||||
({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
min,
|
||||
max,
|
||||
helperText,
|
||||
className = "",
|
||||
placeholder = "Select a date",
|
||||
size = "lg",
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
}
|
||||
return new Date();
|
||||
});
|
||||
|
||||
const dropdownRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const portalContainerRef = useRef(null);
|
||||
|
||||
// Parse the selected date from value prop
|
||||
const selectedDate = useMemo(() => {
|
||||
return value ? new Date(value) : null;
|
||||
}, [value]);
|
||||
|
||||
// Create portal container on mount, cleanup on unmount
|
||||
useEffect(() => {
|
||||
if (!portalContainerRef.current) {
|
||||
portalContainerRef.current = document.createElement("div");
|
||||
portalContainerRef.current.style.position = "absolute";
|
||||
portalContainerRef.current.style.zIndex = "9999";
|
||||
document.body.appendChild(portalContainerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (
|
||||
portalContainerRef.current &&
|
||||
document.body.contains(portalContainerRef.current)
|
||||
) {
|
||||
document.body.removeChild(portalContainerRef.current);
|
||||
portalContainerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update dropdown position when opened
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (inputRef.current && portalContainerRef.current) {
|
||||
const rect = inputRef.current.getBoundingClientRect();
|
||||
const scrollTop =
|
||||
window.pageYOffset || document.documentElement.scrollTop;
|
||||
const scrollLeft =
|
||||
window.pageXOffset || document.documentElement.scrollLeft;
|
||||
|
||||
portalContainerRef.current.style.top = `${rect.bottom + scrollTop + 8}px`;
|
||||
portalContainerRef.current.style.left = `${rect.left + scrollLeft}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add small delay to prevent immediate closing
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Update position on scroll or resize when dropdown is open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
updateDropdownPosition();
|
||||
|
||||
const handleScroll = () => updateDropdownPosition();
|
||||
const handleResize = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Update current month when value changes
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Memoized event handlers
|
||||
const handleDateSelect = useCallback(
|
||||
(date) => {
|
||||
const formattedDate = formatInputDate(date);
|
||||
onChange(formattedDate);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleToggleOpen = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setIsOpen((prev) => !prev);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const navigateMonth = useCallback((direction) => {
|
||||
setCurrentMonth((prev) => {
|
||||
const newMonth = new Date(prev);
|
||||
newMonth.setMonth(prev.getMonth() + direction);
|
||||
return newMonth;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
handleDateSelect(new Date());
|
||||
}, [handleDateSelect]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
// Memoize date checking functions
|
||||
const isDateDisabled = useCallback(
|
||||
(date) => {
|
||||
if (!date) return false;
|
||||
if (min && date < new Date(min)) return true;
|
||||
if (max && date > new Date(max)) return true;
|
||||
return false;
|
||||
},
|
||||
[min, max],
|
||||
);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(date) => {
|
||||
return isSameDate(date, selectedDate);
|
||||
},
|
||||
[selectedDate],
|
||||
);
|
||||
|
||||
// Memoize calendar days
|
||||
const days = useMemo(() => getCalendarDays(currentMonth), [currentMonth]);
|
||||
|
||||
// Memoize formatted display value
|
||||
const displayValue = useMemo(() => {
|
||||
return selectedDate ? formatDisplayDate(selectedDate) : placeholder;
|
||||
}, [selectedDate, placeholder]);
|
||||
|
||||
// Memoize classes
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
const labelSizeClass = LABEL_SIZE_CLASSES[size];
|
||||
|
||||
const inputClasses = useMemo(
|
||||
() =>
|
||||
`
|
||||
w-full
|
||||
${sizeClass}
|
||||
pl-10 pr-5
|
||||
border-2 border-gray-200 rounded-xl shadow-sm
|
||||
transition-all duration-200
|
||||
cursor-pointer
|
||||
flex items-center justify-between
|
||||
${error ? `${getThemeClasses("input-border-error")}` : isOpen ? getThemeClasses("datepicker-focus") : "border-gray-300 hover:border-gray-400"}
|
||||
${disabled ? "bg-gray-50 cursor-not-allowed" : "bg-white"}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
[sizeClass, error, isOpen, disabled, getThemeClasses],
|
||||
);
|
||||
|
||||
const labelClasses = useMemo(
|
||||
() =>
|
||||
`block ${labelSizeClass} font-semibold text-gray-700 mb-3 flex items-center`,
|
||||
[labelSizeClass],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${className} relative`}>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}
|
||||
{required && <span className={`${getThemeClasses("text-danger")} ml-1`}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={inputRef}
|
||||
onClick={handleToggleOpen}
|
||||
className={inputClasses}
|
||||
>
|
||||
<CalendarDaysIcon className="absolute left-3 h-5 w-5 text-gray-400" />
|
||||
<span className={selectedDate ? "text-gray-900" : "text-gray-400"}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<CalendarDaysIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Calendar Dropdown - Rendered as Portal */}
|
||||
{isOpen &&
|
||||
!disabled &&
|
||||
portalContainerRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="w-80 bg-white border border-gray-200 rounded-xl shadow-2xl p-4"
|
||||
style={{ maxHeight: "400px" }}
|
||||
>
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateMonth(-1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{MONTH_NAMES[currentMonth.getMonth()]}{" "}
|
||||
{currentMonth.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateMonth(1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day Headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{DAY_HEADERS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-sm font-medium text-gray-500 py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Days */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const disabled = !date || isDateDisabled(date);
|
||||
const selected = isSelected(date);
|
||||
const today = isToday(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
date && !disabled && handleDateSelect(date)
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
h-10 w-10 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${!date ? "invisible" : ""}
|
||||
${disabled ? "text-gray-300 cursor-not-allowed" : "hover:bg-gray-100 cursor-pointer"}
|
||||
${selected ? getThemeClasses("datepicker-selected") : today ? getThemeClasses("datepicker-today") : "text-gray-700"}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}
|
||||
>
|
||||
{date?.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToday}
|
||||
className={`text-sm ${getThemeClasses("link-primary")} font-medium`}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
{selectedDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
portalContainerRef.current,
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-xs text-gray-500">{helperText}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-error")} flex items-center`}>
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1 flex-shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DatePicker.displayName = "DatePicker";
|
||||
|
||||
export default DatePicker;
|
||||
646
web/maplefile-frontend/src/components/UIX/DateTime/DateTime.jsx
Normal file
646
web/maplefile-frontend/src/components/UIX/DateTime/DateTime.jsx
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
// File: web/frontend/src/components/UI/DateTime/DateTime.jsx
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from "react";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Constants outside component to prevent recreation
|
||||
const QUICK_TIME_OPTIONS = [
|
||||
"09:00",
|
||||
"10:00",
|
||||
"11:00",
|
||||
"14:00",
|
||||
"15:00",
|
||||
"16:00",
|
||||
];
|
||||
const QUICK_DURATION_OPTIONS = [1, 2, 4, 6, 8, 12];
|
||||
|
||||
// Helper functions outside component
|
||||
const formatTime12Hour = (time24) => {
|
||||
if (!time24) return "";
|
||||
const [hours, minutes] = time24.split(":").map(Number);
|
||||
const period = hours >= 12 ? "pm" : "am";
|
||||
const hours12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
return `${hours12}:${String(minutes).padStart(2, "0")} ${period}`;
|
||||
};
|
||||
|
||||
const formatDateShort = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
const dt = new Date(year, month - 1, day);
|
||||
return dt.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateLong = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
const dt = new Date(year, month - 1, day);
|
||||
return dt.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const calculateDuration = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) return null;
|
||||
|
||||
const [startHours, startMinutes] = startTime.split(":").map(Number);
|
||||
const [endHours, endMinutes] = endTime.split(":").map(Number);
|
||||
|
||||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||
|
||||
let durationMinutes = endTotalMinutes - startTotalMinutes;
|
||||
|
||||
// Handle next day scenario
|
||||
if (durationMinutes < 0) {
|
||||
durationMinutes += 24 * 60;
|
||||
}
|
||||
|
||||
const hours = Math.floor(durationMinutes / 60);
|
||||
const minutes = durationMinutes % 60;
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateEndTime = (startTime, durationMinutes) => {
|
||||
const [hours, minutes] = startTime.split(":").map(Number);
|
||||
const startMinutes = hours * 60 + minutes;
|
||||
const endMinutes = startMinutes + durationMinutes;
|
||||
|
||||
const endHours = Math.floor(endMinutes / 60) % 24;
|
||||
const endMins = endMinutes % 60;
|
||||
|
||||
return `${String(endHours).padStart(2, "0")}:${String(endMins).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getDateOffset = (offsetDays) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + offsetDays);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getNextMonth = () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* DateTime Component
|
||||
* Combined date and time picker with support for start/end times
|
||||
*/
|
||||
const DateTime = memo(
|
||||
({
|
||||
id,
|
||||
label,
|
||||
value = { date: "", startTime: "", endTime: "" },
|
||||
onChange,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
minTime = "00:00",
|
||||
maxTime = "23:59",
|
||||
helperText,
|
||||
className = "",
|
||||
placeholder = "Select date and time",
|
||||
showSeconds = false,
|
||||
enableEndTime = true,
|
||||
defaultDuration = 60,
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("date");
|
||||
const pickerRef = useRef(null);
|
||||
|
||||
// Normalize value to ensure correct structure
|
||||
const normalizedValue = useMemo(
|
||||
() => ({
|
||||
date: value?.date || "",
|
||||
startTime: value?.startTime || "",
|
||||
endTime: value?.endTime || "",
|
||||
}),
|
||||
[value?.date, value?.startTime, value?.endTime],
|
||||
);
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
textDanger: getThemeClasses("text-danger"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
hoverTextPrimary: getThemeClasses("hover:text-primary"),
|
||||
inputBorder: getThemeClasses("input-border"),
|
||||
inputBorderError: getThemeClasses("input-border-error"),
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize display value
|
||||
const displayValue = useMemo(() => {
|
||||
const { date, startTime, endTime } = normalizedValue;
|
||||
|
||||
if (!date && !startTime && !endTime) return "";
|
||||
|
||||
let display = "";
|
||||
|
||||
if (date) {
|
||||
display = formatDateLong(date);
|
||||
}
|
||||
|
||||
if (startTime) {
|
||||
display += display ? ", " : "";
|
||||
display += formatTime12Hour(startTime);
|
||||
|
||||
if (enableEndTime && endTime) {
|
||||
display += ` - ${formatTime12Hour(endTime)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return display;
|
||||
}, [normalizedValue, enableEndTime]);
|
||||
|
||||
// Memoize duration
|
||||
const duration = useMemo(() => {
|
||||
if (!enableEndTime) return null;
|
||||
return calculateDuration(
|
||||
normalizedValue.startTime,
|
||||
normalizedValue.endTime,
|
||||
);
|
||||
}, [enableEndTime, normalizedValue.startTime, normalizedValue.endTime]);
|
||||
|
||||
// Memoize time range validation
|
||||
const timeRangeError = useMemo(() => {
|
||||
if (!enableEndTime) return null;
|
||||
const { startTime, endTime } = normalizedValue;
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
return "End time must be after start time";
|
||||
}
|
||||
return null;
|
||||
}, [enableEndTime, normalizedValue.startTime, normalizedValue.endTime]);
|
||||
|
||||
// Event handlers
|
||||
const handleDateChange = useCallback(
|
||||
(newDate) => {
|
||||
onChange({
|
||||
...normalizedValue,
|
||||
date: newDate,
|
||||
});
|
||||
},
|
||||
[onChange, normalizedValue],
|
||||
);
|
||||
|
||||
const handleStartTimeChange = useCallback(
|
||||
(newStartTime) => {
|
||||
let newEndTime = normalizedValue.endTime;
|
||||
|
||||
if (enableEndTime && (!newEndTime || newStartTime >= newEndTime)) {
|
||||
newEndTime = calculateEndTime(newStartTime, defaultDuration);
|
||||
}
|
||||
|
||||
onChange({
|
||||
...normalizedValue,
|
||||
startTime: newStartTime,
|
||||
...(enableEndTime ? { endTime: newEndTime } : {}),
|
||||
});
|
||||
},
|
||||
[onChange, normalizedValue, enableEndTime, defaultDuration],
|
||||
);
|
||||
|
||||
const handleEndTimeChange = useCallback(
|
||||
(newEndTime) => {
|
||||
if (
|
||||
normalizedValue.startTime &&
|
||||
newEndTime <= normalizedValue.startTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...normalizedValue,
|
||||
endTime: newEndTime,
|
||||
});
|
||||
},
|
||||
[onChange, normalizedValue],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
onChange({ date: "", startTime: "", endTime: "" });
|
||||
setShowPicker(false);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const togglePicker = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setShowPicker((prev) => !prev);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||
e.preventDefault();
|
||||
setShowPicker((prev) => !prev);
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const handleTomorrow = useCallback(() => {
|
||||
handleDateChange(getDateOffset(1));
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleNextWeek = useCallback(() => {
|
||||
handleDateChange(getDateOffset(7));
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
handleDateChange(getNextMonth());
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
setShowPicker(false);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setShowPicker(false);
|
||||
}, []);
|
||||
|
||||
// Click outside handler
|
||||
useEffect(() => {
|
||||
if (!showPicker) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target)) {
|
||||
setShowPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delay to prevent immediate closing
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [showPicker]);
|
||||
|
||||
// Memoize quick duration options
|
||||
const quickDurationButtons = useMemo(() => {
|
||||
if (!normalizedValue.startTime) return null;
|
||||
|
||||
return QUICK_DURATION_OPTIONS.map((hours) => {
|
||||
const endTime = calculateEndTime(normalizedValue.startTime, hours * 60);
|
||||
const label = `+${hours}h`;
|
||||
|
||||
return { hours, endTime, label };
|
||||
});
|
||||
}, [normalizedValue.startTime]);
|
||||
|
||||
// Memoize input classes
|
||||
const inputClasses = useMemo(
|
||||
() =>
|
||||
`
|
||||
w-full px-4 py-3 pr-10
|
||||
border rounded-lg
|
||||
transition-all duration-200
|
||||
cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-1
|
||||
${error || timeRangeError ? themeClasses.inputBorderError : themeClasses.inputBorder}
|
||||
${disabled ? `${themeClasses.bgDisabled} cursor-not-allowed opacity-60` : themeClasses.bgCard}
|
||||
${showPicker ? themeClasses.inputFocusRing : ""}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
[error, timeRangeError, disabled, showPicker, themeClasses],
|
||||
);
|
||||
|
||||
const labelClasses = useMemo(
|
||||
() =>
|
||||
`block text-base sm:text-lg font-semibold ${themeClasses.textPrimary} mb-3 flex items-center`,
|
||||
[themeClasses.textPrimary],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`mb-5 ${className}`}>
|
||||
{label && (
|
||||
<div className={labelClasses}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className={`ml-1 ${themeClasses.textDanger}`}>*</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={pickerRef}>
|
||||
{/* Display Input */}
|
||||
<div
|
||||
id={id}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
role="button"
|
||||
onClick={togglePicker}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClasses}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon
|
||||
className={`h-5 w-5 ${themeClasses.textMuted} mr-2`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
displayValue
|
||||
? themeClasses.textPrimary
|
||||
: themeClasses.textMuted
|
||||
}
|
||||
>
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
</div>
|
||||
{duration && (
|
||||
<span className={`text-sm ${themeClasses.textMuted} mr-6`}>
|
||||
({duration})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear button */}
|
||||
{displayValue && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 ${themeClasses.textMuted} ${themeClasses.hoverTextPrimary}`}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Picker Dropdown */}
|
||||
{showPicker && !disabled && (
|
||||
<div
|
||||
className={`absolute z-50 mt-1 ${themeClasses.bgCard} border ${themeClasses.inputBorder} rounded-lg shadow-lg w-80`}
|
||||
>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("date")}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "date"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4 inline mr-1" />
|
||||
Date
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("startTime")}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "startTime"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 inline mr-1" />
|
||||
Start
|
||||
</button>
|
||||
{enableEndTime && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("endTime")}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "endTime"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 inline mr-1" />
|
||||
End
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{activeTab === "date" ? (
|
||||
<div>
|
||||
<input
|
||||
type="date"
|
||||
value={normalizedValue.date}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Quick date options */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTomorrow}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
Tomorrow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextWeek}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
Next week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextMonth}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
Next month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "startTime" ? (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-2">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={normalizedValue.startTime}
|
||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||
min={minTime}
|
||||
max={maxTime}
|
||||
step={showSeconds ? 1 : 60}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Quick time options */}
|
||||
<div className="mt-3 grid grid-cols-3 gap-1">
|
||||
{QUICK_TIME_OPTIONS.map((time) => (
|
||||
<button
|
||||
key={time}
|
||||
type="button"
|
||||
onClick={() => handleStartTimeChange(time)}
|
||||
className="px-2 py-1 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
{formatTime12Hour(time)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-2">
|
||||
End Time
|
||||
{normalizedValue.startTime && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
(after {formatTime12Hour(normalizedValue.startTime)})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={normalizedValue.endTime}
|
||||
onChange={(e) => handleEndTimeChange(e.target.value)}
|
||||
min={normalizedValue.startTime || minTime}
|
||||
max={maxTime}
|
||||
step={showSeconds ? 1 : 60}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Quick duration options from start time */}
|
||||
{quickDurationButtons && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-1">
|
||||
{quickDurationButtons.map(
|
||||
({ hours, endTime, label }) => (
|
||||
<button
|
||||
key={hours}
|
||||
type="button"
|
||||
onClick={() => handleEndTimeChange(endTime)}
|
||||
className="px-2 py-1 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
{(normalizedValue.date ||
|
||||
normalizedValue.startTime ||
|
||||
normalizedValue.endTime) && (
|
||||
<div className="px-4 py-2 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
{normalizedValue.date && (
|
||||
<span className="text-gray-600">
|
||||
📅 {formatDateShort(normalizedValue.date)}
|
||||
</span>
|
||||
)}
|
||||
{normalizedValue.startTime && (
|
||||
<span className="text-gray-600">
|
||||
⏰ {formatTime12Hour(normalizedValue.startTime)}
|
||||
{enableEndTime &&
|
||||
normalizedValue.endTime &&
|
||||
` - ${formatTime12Hour(normalizedValue.endTime)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{duration && (
|
||||
<span className="text-gray-500 text-xs">{duration}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDone}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helperText && !error && !timeRangeError && (
|
||||
<p className={`mt-2 text-sm ${themeClasses.textMuted}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
{(error || timeRangeError) && (
|
||||
<p
|
||||
className={`mt-2 text-sm ${themeClasses.textDanger} flex items-center`}
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1 flex-shrink-0" />
|
||||
{error || timeRangeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DateTime.displayName = "DateTime";
|
||||
|
||||
// Export with multiple names for flexibility
|
||||
export default DateTime;
|
||||
export { DateTime as DateTimePicker };
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// File Path: web/frontend/src/components/UIX/DeleteButton/DeleteButton.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Move constants outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: "px-3 py-2 text-xs sm:text-sm",
|
||||
md: "px-5 py-3 text-sm sm:text-base",
|
||||
lg: "px-6 py-4 text-base sm:text-lg",
|
||||
};
|
||||
|
||||
const ICON_SIZE_CLASSES = {
|
||||
sm: "h-3 w-3 sm:h-4 sm:w-4",
|
||||
md: "h-4 w-4 sm:h-5 sm:w-5",
|
||||
lg: "h-5 w-5 sm:h-6 sm:w-6",
|
||||
};
|
||||
|
||||
/**
|
||||
* DeleteButton Component
|
||||
* Standardized button/link for delete actions
|
||||
* Uses red theme for danger actions with blue focus ring for consistency
|
||||
*
|
||||
* @param {Function} onClick - Click handler function (for button)
|
||||
* @param {string} to - Navigation path (for Link - use this OR onClick, not both)
|
||||
* @param {string} text - Button text (default: "Delete")
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to TrashIcon)
|
||||
* @param {string} size - Button size (sm, md, lg)
|
||||
* @param {boolean} asLink - Whether to render as Link component (requires 'to' prop)
|
||||
*/
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onClick,
|
||||
to,
|
||||
text = "Delete",
|
||||
className = "",
|
||||
disabled = false,
|
||||
icon: Icon = TrashIcon,
|
||||
size = "md",
|
||||
asLink = false,
|
||||
...props
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes to prevent repeated calls
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
inputFocusRing: getThemeClasses("input-focus-ring"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize size classes
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
const iconSizeClass = ICON_SIZE_CLASSES[size];
|
||||
|
||||
// Memoize title
|
||||
const title = useMemo(() => `${text} Item`, [text]);
|
||||
|
||||
// Memoize base classes
|
||||
const baseClasses = useMemo(
|
||||
() =>
|
||||
`
|
||||
inline-flex items-center
|
||||
${sizeClass}
|
||||
rounded-xl shadow-sm font-medium
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
transition-all duration-200
|
||||
border border-red-200 text-red-700 bg-red-50
|
||||
hover:bg-red-100 hover:shadow-md
|
||||
${themeClasses.inputFocusRing}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||
${className}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
[sizeClass, themeClasses.inputFocusRing, disabled, className],
|
||||
);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(() => `${iconSizeClass} mr-2`, [iconSizeClass]);
|
||||
|
||||
// Memoize keydown handler for link-style button
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && onClick && !disabled) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick, disabled],
|
||||
);
|
||||
|
||||
// Memoize content to prevent recreation
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon && <Icon className={iconClasses} />}
|
||||
{text}
|
||||
</>
|
||||
),
|
||||
[Icon, iconClasses, text],
|
||||
);
|
||||
|
||||
// If 'to' prop is provided, render as a Link
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={baseClasses}
|
||||
title={title}
|
||||
onClick={disabled ? (e) => e.preventDefault() : undefined}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// If asLink is true but no 'to', render as a div with onClick
|
||||
if (asLink) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={baseClasses}
|
||||
title={title}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render as button
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={baseClasses}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DeleteButton.displayName = "DeleteButton";
|
||||
|
||||
export default DeleteButton;
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback, memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
XMarkIcon,
|
||||
TrashIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PencilSquareIcon,
|
||||
ArrowLeftIcon,
|
||||
LockClosedIcon,
|
||||
ShieldExclamationIcon,
|
||||
ClockIcon,
|
||||
UserIcon,
|
||||
GlobeAltIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
// Constants outside component
|
||||
const GRADIENT_STYLE = {
|
||||
background: "linear-gradient(to right, #8a1622, #dc2626)",
|
||||
};
|
||||
|
||||
// Helper functions outside component
|
||||
const formatDate = (dateString) => {
|
||||
return dateString ? new Date(dateString).toLocaleDateString() : "N/A";
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return dateString ? new Date(dateString).toLocaleString() : "Not available";
|
||||
};
|
||||
|
||||
const getItemDisplayName = (item) => {
|
||||
return item?.name || item?.text || "item name";
|
||||
};
|
||||
|
||||
const getItemStatus = (status) => {
|
||||
return status === 1 ? "Active" : "Inactive";
|
||||
};
|
||||
|
||||
const DeleteConfirmationCard = memo(
|
||||
({
|
||||
item,
|
||||
itemType,
|
||||
isDeleting,
|
||||
error,
|
||||
confirmText,
|
||||
onConfirmTextChange,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onErrorClear,
|
||||
detailRoute,
|
||||
editRoute,
|
||||
impactWarnings = [],
|
||||
alternativeText,
|
||||
customFields = [],
|
||||
systemInfo = true,
|
||||
className = "",
|
||||
}) => {
|
||||
if (!item) return null;
|
||||
|
||||
// Memoized values
|
||||
const itemDisplayName = useMemo(() => getItemDisplayName(item), [item]);
|
||||
const itemStatus = useMemo(() => getItemStatus(item.status), [item.status]);
|
||||
const createdDate = useMemo(
|
||||
() => formatDate(item.createdAt),
|
||||
[item.createdAt],
|
||||
);
|
||||
const createdDateTime = useMemo(
|
||||
() => formatDateTime(item.createdAt),
|
||||
[item.createdAt],
|
||||
);
|
||||
const modifiedDateTime = useMemo(
|
||||
() => formatDateTime(item.modifiedAt),
|
||||
[item.modifiedAt],
|
||||
);
|
||||
|
||||
const isConfirmValid = useMemo(
|
||||
() => confirmText === itemDisplayName,
|
||||
[confirmText, itemDisplayName],
|
||||
);
|
||||
|
||||
const itemTypeLower = useMemo(() => itemType.toLowerCase(), [itemType]);
|
||||
|
||||
// Memoized callbacks
|
||||
const handleConfirmTextChange = useCallback(
|
||||
(e) => {
|
||||
onConfirmTextChange(e.target.value);
|
||||
},
|
||||
[onConfirmTextChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (isConfirmValid && !isDeleting) {
|
||||
onDelete();
|
||||
}
|
||||
}, [isConfirmValid, isDeleting, onDelete]);
|
||||
|
||||
// Memoized class strings
|
||||
const inputClasses = useMemo(() => {
|
||||
const baseClasses =
|
||||
"w-full px-4 py-3 border-2 rounded-xl focus:ring-4 focus:outline-none transition-all duration-200 text-sm font-medium";
|
||||
if (isConfirmValid) {
|
||||
return `${baseClasses} border-green-400 bg-green-50 focus:ring-green-200 text-green-800`;
|
||||
}
|
||||
return `${baseClasses} border-red-300 bg-red-50 focus:ring-red-200 text-red-800`;
|
||||
}, [isConfirmValid]);
|
||||
|
||||
const deleteButtonClasses = useMemo(
|
||||
() =>
|
||||
"inline-flex items-center justify-center px-6 py-3 text-sm font-bold text-white bg-gradient-to-r from-red-700 to-red-800 rounded-xl hover:from-red-800 hover:to-red-900 hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-red-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg",
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelButtonClasses = useMemo(
|
||||
() =>
|
||||
"inline-flex items-center justify-center px-6 py-3 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-300 rounded-xl hover:bg-gray-50 hover:border-gray-400 hover:shadow-md focus:outline-none focus:ring-4 focus:ring-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200",
|
||||
[],
|
||||
);
|
||||
|
||||
const editLinkClasses = useMemo(
|
||||
() =>
|
||||
`inline-flex items-center justify-center px-6 py-3 text-sm font-semibold text-white bg-gradient-to-r from-red-500 to-red-600 rounded-xl hover:from-red-600 hover:to-red-700 hover:shadow-lg focus:outline-none focus:ring-4 focus:ring-red-200 transition-all duration-200 ${
|
||||
isDeleting ? "opacity-50 pointer-events-none" : ""
|
||||
}`,
|
||||
[isDeleting],
|
||||
);
|
||||
|
||||
// Memoized components
|
||||
const errorSection = useMemo(() => {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg flex items-center justify-between">
|
||||
<span className="flex items-center">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 mr-2" />
|
||||
{error}
|
||||
</span>
|
||||
<button
|
||||
onClick={onErrorClear}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}, [error, onErrorClear]);
|
||||
|
||||
const impactWarningSection = useMemo(() => {
|
||||
if (impactWarnings.length === 0) return null;
|
||||
return (
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg mb-6">
|
||||
<h4 className="text-base font-medium text-amber-800 mb-3 flex items-center">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 mr-2" />
|
||||
This deletion will affect:
|
||||
</h4>
|
||||
<ul className="text-sm text-amber-700 space-y-1 ml-7">
|
||||
{impactWarnings.map((warning, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
{warning.icon && (
|
||||
<warning.icon className="w-4 h-4 mr-2 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span>{warning.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{alternativeText && (
|
||||
<div className="mt-3 pt-3 border-t border-amber-200">
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
<strong>Alternative:</strong> {alternativeText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [impactWarnings, alternativeText]);
|
||||
|
||||
const customFieldsSection = useMemo(() => {
|
||||
return customFields.map((field, index) => (
|
||||
<div key={index}>
|
||||
<div className="block text-base font-medium text-gray-700 mb-2">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-gray-200 flex items-center">
|
||||
{field.icon && <field.icon className="w-5 h-5 mr-2 text-red-500" />}
|
||||
<span className={field.className || "font-medium text-red-700"}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}, [customFields]);
|
||||
|
||||
const systemInfoSection = useMemo(() => {
|
||||
if (!systemInfo) return null;
|
||||
return (
|
||||
<div className="mt-8 bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 hover:shadow-2xl transition-shadow duration-300">
|
||||
<div className="px-6 py-4 bg-blue-50 border-b border-blue-200">
|
||||
<h2 className="text-lg font-bold text-blue-900 flex items-center">
|
||||
<InformationCircleIcon className="w-6 h-6 mr-3 text-blue-600" />
|
||||
System Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<ClockIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Created At:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">{createdDateTime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<UserIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Created By:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">
|
||||
{item.createdByUserName || "Not available"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<ClockIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Last Modified:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">{modifiedDateTime}</p>
|
||||
</div>
|
||||
{item.modifiedByUserName && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<UserIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Modified By:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">
|
||||
{item.modifiedByUserName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.createdFromIpAddress && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<GlobeAltIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Created From IP:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">
|
||||
{item.createdFromIpAddress}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.modifiedFromIpAddress && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-500 mb-1 flex items-center">
|
||||
<GlobeAltIcon className="w-4 h-4 mr-1 text-blue-600" />
|
||||
Modified From IP:
|
||||
</p>
|
||||
<p className="text-gray-900 ml-5">
|
||||
{item.modifiedFromIpAddress}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [systemInfo, item, createdDateTime, modifiedDateTime]);
|
||||
|
||||
return (
|
||||
<div className={`max-w-4xl mx-auto ${className}`}>
|
||||
{/* Permanent Deletion Warning Alert */}
|
||||
<div className="mb-6 bg-red-50 border-2 border-red-300 text-red-800 px-4 py-4 rounded-lg flex items-start">
|
||||
<ShieldExclamationIcon className="w-6 h-6 mr-3 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-lg">Permanent Deletion Warning</p>
|
||||
<p className="text-sm mt-1">
|
||||
You are about to permanently delete this {itemTypeLower}. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 hover:shadow-2xl transition-shadow duration-300">
|
||||
<div className="px-6 py-4" style={GRADIENT_STYLE}>
|
||||
<h2 className="text-base font-bold text-white uppercase tracking-wider flex items-center">
|
||||
<ShieldExclamationIcon className="w-5 h-5 mr-2" />
|
||||
Deletion Confirmation
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Error Messages */}
|
||||
{errorSection}
|
||||
|
||||
{/* Item Details Section */}
|
||||
<div className="mb-6">
|
||||
<div className="block text-base sm:text-lg font-semibold text-gray-700 mb-3 flex items-center">
|
||||
{itemType} to be deleted
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-red-50 rounded-xl border border-red-200">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="block text-base sm:text-lg font-semibold text-gray-700 mb-3">
|
||||
Name
|
||||
</div>
|
||||
<div className="p-5 bg-white rounded-xl border border-gray-200 font-semibold text-lg">
|
||||
{itemDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<div>
|
||||
<div className="block text-base font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-gray-200">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFieldsSection}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm pt-2 border-t border-red-200">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Status:</span>{" "}
|
||||
<span className="text-gray-900">{itemStatus}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">
|
||||
Created:
|
||||
</span>{" "}
|
||||
<span className="text-gray-900">{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impact Warning */}
|
||||
{impactWarningSection}
|
||||
|
||||
{/* Confirmation Section */}
|
||||
<div className="p-6 bg-gradient-to-br from-red-50 via-red-25 to-white border-2 border-red-200 rounded-xl mb-6 shadow-lg">
|
||||
<h4 className="text-lg font-bold text-red-800 mb-4 flex items-center">
|
||||
<LockClosedIcon className="w-6 h-6 mr-3 text-red-600" />
|
||||
Confirmation Required
|
||||
</h4>
|
||||
|
||||
<div className="bg-white bg-opacity-60 backdrop-blur-sm rounded-lg p-4 mb-4 border border-red-100">
|
||||
<p className="text-sm text-gray-800 mb-3 leading-relaxed">
|
||||
This action will permanently remove the {itemTypeLower} from
|
||||
the system. All data will be lost and cannot be recovered.
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="delete-confirmation-input"
|
||||
className="text-sm font-semibold text-gray-900 mb-4 block"
|
||||
>
|
||||
To confirm deletion, please type{" "}
|
||||
<code className="px-3 py-1 bg-red-100 border border-red-300 rounded-md text-red-700 font-mono text-sm">
|
||||
{itemDisplayName}
|
||||
</code>{" "}
|
||||
in the box below:
|
||||
</label>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="delete-confirmation-input"
|
||||
name="delete-confirmation"
|
||||
value={confirmText}
|
||||
onChange={handleConfirmTextChange}
|
||||
placeholder={`Type "${itemDisplayName}" to confirm`}
|
||||
className={inputClasses}
|
||||
disabled={isDeleting}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{isConfirmValid && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<svg
|
||||
className="w-5 h-5 text-green-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-8 border-t-2 border-red-100 flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<Link
|
||||
to={detailRoute}
|
||||
className="inline-flex items-center text-sm font-medium text-red-600 hover:text-red-800 transition-colors duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Detail
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col space-y-3 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className={cancelButtonClasses}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{editRoute && (
|
||||
<Link to={editRoute} className={editLinkClasses}>
|
||||
<PencilSquareIcon className="w-4 h-4 mr-2" />
|
||||
Edit Instead
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || !isConfirmValid}
|
||||
className={deleteButtonClasses}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 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>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="w-5 h-5 mr-2" />
|
||||
Delete Permanently
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Information Card */}
|
||||
{systemInfoSection}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DeleteConfirmationCard.displayName = "DeleteConfirmationCard";
|
||||
|
||||
export default DeleteConfirmationCard;
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// File Path: web/frontend/src/components/UIX/DetailCard/DetailCard.jsx
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Constants outside component to prevent recreation
|
||||
const MAX_WIDTH_CLASSES = {
|
||||
"2xl": "max-w-2xl",
|
||||
"3xl": "max-w-3xl",
|
||||
"4xl": "max-w-4xl",
|
||||
"5xl": "max-w-5xl",
|
||||
"6xl": "max-w-6xl",
|
||||
"7xl": "max-w-7xl",
|
||||
full: "max-w-full",
|
||||
};
|
||||
|
||||
/**
|
||||
* DetailCard Component
|
||||
* Blue-themed card for displaying information on detail pages
|
||||
* Provides consistent styling for information display sections
|
||||
*
|
||||
* @param {string} title - Card title
|
||||
* @param {React.ComponentType} icon - Icon component for header
|
||||
* @param {React.ReactNode} children - Card content
|
||||
* @param {string} className - Additional CSS classes for the container
|
||||
* @param {string} maxWidth - Maximum width constraint (2xl, 3xl, 4xl, 5xl, full)
|
||||
* @param {boolean} gradient - Whether to use gradient background in header (default: true)
|
||||
*/
|
||||
const DetailCard = memo(
|
||||
({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
className = "",
|
||||
maxWidth = "4xl",
|
||||
gradient = true,
|
||||
...props
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes to prevent repeated calls
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
buttonPrimary: gradient ? null : getThemeClasses("button-primary"),
|
||||
bgGradientSecondary: gradient
|
||||
? getThemeClasses("bg-gradient-secondary")
|
||||
: null,
|
||||
}),
|
||||
[getThemeClasses, gradient],
|
||||
);
|
||||
|
||||
// Memoize the max width class
|
||||
const maxWidthClass = useMemo(
|
||||
() => MAX_WIDTH_CLASSES[maxWidth] || MAX_WIDTH_CLASSES["4xl"],
|
||||
[maxWidth],
|
||||
);
|
||||
|
||||
// Memoize header classes based on gradient prop
|
||||
const headerClasses = useMemo(() => {
|
||||
if (!gradient) {
|
||||
return themeClasses.buttonPrimary;
|
||||
}
|
||||
return themeClasses.bgGradientSecondary;
|
||||
}, [
|
||||
gradient,
|
||||
themeClasses.buttonPrimary,
|
||||
themeClasses.bgGradientSecondary,
|
||||
]);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() => `${maxWidthClass} mx-auto ${className}`.trim(),
|
||||
[maxWidthClass, className],
|
||||
);
|
||||
|
||||
// Memoize card classes
|
||||
const cardClasses = useMemo(
|
||||
() =>
|
||||
`${themeClasses.bgCard} shadow-xl rounded-2xl overflow-hidden border ${themeClasses.cardBorder} hover:shadow-2xl transition-shadow duration-300`.trim(),
|
||||
[themeClasses.bgCard, themeClasses.cardBorder],
|
||||
);
|
||||
|
||||
// Memoize header content
|
||||
const headerContent = useMemo(() => {
|
||||
if (!title) return null;
|
||||
|
||||
return (
|
||||
<div className={`px-6 py-4 ${headerClasses}`}>
|
||||
<h2 className="text-base font-bold text-white uppercase tracking-wider flex items-center">
|
||||
{Icon && <Icon className="w-5 h-5 mr-2" />}
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}, [title, Icon, headerClasses]);
|
||||
|
||||
return (
|
||||
<div className={containerClasses} {...props}>
|
||||
<div className={cardClasses}>
|
||||
{/* Header */}
|
||||
{headerContent}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DetailCard.displayName = "DetailCard";
|
||||
|
||||
export default DetailCard;
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
// File Path: src/components/UIX/DetailFullView/DetailFullView.jsx
|
||||
// Reusable DetailFullView component for comprehensive entity detail pages
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import {
|
||||
UIXThemeProvider,
|
||||
useUIXTheme,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Alert,
|
||||
InfoCard,
|
||||
} from "../";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* Reusable DetailFullView Component
|
||||
* A complete entity detail view component that provides consistent layout and theming
|
||||
* for comprehensive detail pages with multiple information sections
|
||||
*/
|
||||
|
||||
// Inner component that uses the theme hook
|
||||
const DetailFullViewInner = memo(
|
||||
({
|
||||
entityData,
|
||||
breadcrumbItems,
|
||||
headerConfig,
|
||||
mainInfoCard,
|
||||
contentSections,
|
||||
actionButtons,
|
||||
tabs,
|
||||
alerts,
|
||||
onUnauthorized,
|
||||
isLoading,
|
||||
error,
|
||||
onErrorClose,
|
||||
className,
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes for performance
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
borderPrimary: getThemeClasses("border-primary"),
|
||||
textSecondary: getThemeClasses("text-secondary"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize loading text
|
||||
const loadingText = useMemo(
|
||||
() => headerConfig.loadingText || "Loading details...",
|
||||
[headerConfig.loadingText],
|
||||
);
|
||||
|
||||
// Memoize header title
|
||||
const headerTitle = useMemo(
|
||||
() => headerConfig.title || "Entity Details",
|
||||
[headerConfig.title],
|
||||
);
|
||||
|
||||
// Memoize not found text
|
||||
const notFoundTitle = useMemo(
|
||||
() => headerConfig.notFoundTitle || "Item Not Found",
|
||||
[headerConfig.notFoundTitle],
|
||||
);
|
||||
|
||||
const notFoundMessage = useMemo(
|
||||
() =>
|
||||
headerConfig.notFoundMessage ||
|
||||
"The item you're looking for doesn't exist or you don't have permission to view it.",
|
||||
[headerConfig.notFoundMessage],
|
||||
);
|
||||
|
||||
// Memoize entity status checks
|
||||
const isArchived = useMemo(
|
||||
() => entityData && entityData.status === 2,
|
||||
[entityData],
|
||||
);
|
||||
|
||||
const isBanned = useMemo(
|
||||
() => entityData && entityData.isBanned,
|
||||
[entityData],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() =>
|
||||
`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 ${className}`.trim(),
|
||||
[className],
|
||||
);
|
||||
|
||||
// Memoize loading spinner classes
|
||||
const spinnerClasses = useMemo(
|
||||
() =>
|
||||
`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`,
|
||||
[themeClasses.borderPrimary],
|
||||
);
|
||||
|
||||
// Memoize loading component
|
||||
const loadingComponent = useMemo(() => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className={spinnerClasses}></div>
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base ${themeClasses.textSecondary}`}
|
||||
>
|
||||
{loadingText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [isLoading, spinnerClasses, themeClasses.textSecondary, loadingText]);
|
||||
|
||||
// Memoize breadcrumb
|
||||
const breadcrumbComponent = useMemo(() => {
|
||||
if (!breadcrumbItems || breadcrumbItems.length === 0) return null;
|
||||
return <Breadcrumb items={breadcrumbItems} />;
|
||||
}, [breadcrumbItems]);
|
||||
|
||||
// Memoize alerts
|
||||
const alertsComponent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{alerts.archived && isArchived && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={alerts.archived.message || "This item is archived"}
|
||||
icon={alerts.archived.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
{alerts.banned && isBanned && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={alerts.banned.message || "This item is banned"}
|
||||
icon={alerts.banned.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
onClose={onErrorClose}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[
|
||||
alerts.archived,
|
||||
alerts.banned,
|
||||
isArchived,
|
||||
isBanned,
|
||||
error,
|
||||
onErrorClose,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize action buttons
|
||||
const actionButtonsComponent = useMemo(() => {
|
||||
if (!actionButtons || actionButtons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{actionButtons.map((button, index) =>
|
||||
button.component ? (
|
||||
<div key={index}>{button.component}</div>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
variant={button.variant}
|
||||
onClick={button.onClick}
|
||||
disabled={button.disabled}
|
||||
icon={button.icon}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [actionButtons]);
|
||||
|
||||
// Memoize tabs navigation
|
||||
const tabsComponent = useMemo(() => {
|
||||
if (!tabs || tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`px-4 sm:px-6 border-b ${themeClasses.cardBorder}`}>
|
||||
<nav className="-mb-px flex space-x-4 sm:space-x-8 overflow-x-auto scrollbar-hide">
|
||||
{tabs.map((tab, index) =>
|
||||
tab.isActive ? (
|
||||
<div
|
||||
key={index}
|
||||
className={`border-b-2 ${themeClasses.borderPrimary} py-3 sm:py-4 px-1 text-base sm:text-lg font-medium ${themeClasses.textPrimary} whitespace-nowrap flex items-center`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.icon && (
|
||||
<tab.icon className="w-4 sm:w-5 h-4 sm:h-5 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={index}
|
||||
href={tab.to}
|
||||
className={`border-b-2 border-transparent py-3 sm:py-4 px-1 text-base sm:text-lg font-medium ${themeClasses.textSecondary} hover:${themeClasses.textPrimary} hover:${themeClasses.cardBorder} whitespace-nowrap flex items-center`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.icon && (
|
||||
<tab.icon className="w-4 sm:w-5 h-4 sm:h-5 ml-1" />
|
||||
)}
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}, [tabs, themeClasses]);
|
||||
|
||||
// Memoize main info card
|
||||
const mainInfoCardComponent = useMemo(() => {
|
||||
if (!mainInfoCard) return null;
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={mainInfoCard.title}
|
||||
icon={mainInfoCard.icon}
|
||||
avatar={mainInfoCard.avatar}
|
||||
primarySections={mainInfoCard.primarySections || []}
|
||||
secondarySections={mainInfoCard.secondarySections || []}
|
||||
maxWidth={mainInfoCard.maxWidth || "7xl"}
|
||||
showAvatar={mainInfoCard.showAvatar !== false}
|
||||
twoColumn={mainInfoCard.twoColumn !== false}
|
||||
className="mb-6"
|
||||
/>
|
||||
);
|
||||
}, [mainInfoCard]);
|
||||
|
||||
// Memoize content sections
|
||||
const contentSectionsComponent = useMemo(() => {
|
||||
if (!contentSections) return null;
|
||||
|
||||
return contentSections.map((section, index) => {
|
||||
if (section.type === "infoCard") {
|
||||
return (
|
||||
<InfoCard
|
||||
key={index}
|
||||
title={section.title}
|
||||
icon={section.icon}
|
||||
avatar={section.avatar}
|
||||
primarySections={section.primarySections || []}
|
||||
secondarySections={section.secondarySections || []}
|
||||
maxWidth={section.maxWidth || "7xl"}
|
||||
showAvatar={
|
||||
section.showAvatar !== undefined ? section.showAvatar : false
|
||||
}
|
||||
twoColumn={
|
||||
section.twoColumn !== undefined ? section.twoColumn : true
|
||||
}
|
||||
className="mb-6"
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "detailSection") {
|
||||
return (
|
||||
<div key={index} className="mb-6">
|
||||
{section.component}
|
||||
</div>
|
||||
);
|
||||
} else if (section.type === "conditional") {
|
||||
return section.condition ? (
|
||||
<div key={index} className="mb-6">
|
||||
{section.component}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [contentSections]);
|
||||
|
||||
// Memoize no data state
|
||||
const noDataComponent = useMemo(() => {
|
||||
if (entityData || isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-8 sm:py-16 text-center">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-12 sm:w-16 h-12 sm:h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
|
||||
>
|
||||
<UserIcon
|
||||
className={`w-6 sm:w-8 h-6 sm:h-8 ${themeClasses.textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-base sm:text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
{notFoundTitle}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm sm:text-base ${themeClasses.textSecondary} mb-4 sm:mb-6`}
|
||||
>
|
||||
{notFoundMessage}
|
||||
</p>
|
||||
{headerConfig.notFoundAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={headerConfig.notFoundAction.onClick}
|
||||
icon={headerConfig.notFoundAction.icon}
|
||||
size="sm"
|
||||
>
|
||||
{headerConfig.notFoundAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
entityData,
|
||||
isLoading,
|
||||
themeClasses,
|
||||
notFoundTitle,
|
||||
notFoundMessage,
|
||||
headerConfig.notFoundAction,
|
||||
]);
|
||||
|
||||
// Loading state - return early
|
||||
if (isLoading) {
|
||||
return loadingComponent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbComponent}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{alertsComponent}
|
||||
|
||||
{/* Main Content with Header */}
|
||||
<div className="shadow-sm">
|
||||
{entityData && (
|
||||
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
|
||||
{/* Header with Actions */}
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white flex items-center">
|
||||
{headerConfig.icon && (
|
||||
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
|
||||
)}
|
||||
{headerTitle}
|
||||
</h2>
|
||||
{actionButtonsComponent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
className={`bg-white border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
|
||||
>
|
||||
{tabsComponent}
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Main Information Card */}
|
||||
{mainInfoCardComponent}
|
||||
|
||||
{/* Additional Content Sections */}
|
||||
{contentSectionsComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{noDataComponent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name
|
||||
DetailFullViewInner.displayName = "DetailFullViewInner";
|
||||
|
||||
// Main wrapper component that provides theme context
|
||||
const DetailFullView = memo(
|
||||
({
|
||||
// Core data
|
||||
entityData = null,
|
||||
breadcrumbItems = [],
|
||||
headerConfig = {},
|
||||
mainInfoCard = null,
|
||||
contentSections = [],
|
||||
actionButtons = [],
|
||||
tabs = [],
|
||||
|
||||
// Alerts and status
|
||||
alerts = {},
|
||||
|
||||
// Event handlers
|
||||
onUnauthorized = () => {},
|
||||
|
||||
// State
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onErrorClose = () => {},
|
||||
|
||||
// Styling
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<DetailFullViewInner
|
||||
entityData={entityData}
|
||||
breadcrumbItems={breadcrumbItems}
|
||||
headerConfig={headerConfig}
|
||||
mainInfoCard={mainInfoCard}
|
||||
contentSections={contentSections}
|
||||
actionButtons={actionButtons}
|
||||
tabs={tabs}
|
||||
alerts={alerts}
|
||||
onUnauthorized={onUnauthorized}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onErrorClose={onErrorClose}
|
||||
className={className}
|
||||
/>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name
|
||||
DetailFullView.displayName = "DetailFullView";
|
||||
|
||||
export default DetailFullView;
|
||||
|
||||
// Export helper function for reuse in other components
|
||||
export { DetailFullView };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as DetailFullView } from './DetailFullView.jsx';
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
// File Path: web/frontend/src/components/UIX/DetailLiteView/DetailLiteView.jsx
|
||||
// Reusable DetailLiteView component for entity summary pages
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import {
|
||||
UIXThemeProvider,
|
||||
useUIXTheme,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Alert,
|
||||
Tabs,
|
||||
} from "../";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* Reusable DetailLiteView Component
|
||||
* A complete entity summary view component that provides consistent layout and theming
|
||||
*/
|
||||
|
||||
// Inner component that uses the theme hook
|
||||
const DetailLiteViewInner = memo(
|
||||
({
|
||||
entityData,
|
||||
breadcrumbItems,
|
||||
headerConfig,
|
||||
fieldSections,
|
||||
actionButtons,
|
||||
tabs,
|
||||
alerts,
|
||||
onUnauthorized,
|
||||
isLoading,
|
||||
error,
|
||||
onErrorClose,
|
||||
className,
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes for performance
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
badgeError: getThemeClasses("badge-error"),
|
||||
badgePrimary: getThemeClasses("badge-primary"),
|
||||
badgeSecondary: getThemeClasses("badge-secondary"),
|
||||
borderPrimary: getThemeClasses("border-primary"),
|
||||
textSecondary: getThemeClasses("text-secondary"),
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
cardBorder: getThemeClasses("card-border"),
|
||||
bgDisabled: getThemeClasses("bg-disabled"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize text defaults
|
||||
const loadingText = useMemo(
|
||||
() => headerConfig.loadingText || "Loading details...",
|
||||
[headerConfig.loadingText],
|
||||
);
|
||||
|
||||
const headerTitle = useMemo(
|
||||
() => headerConfig.title || "Entity Summary",
|
||||
[headerConfig.title],
|
||||
);
|
||||
|
||||
const notFoundTitle = useMemo(
|
||||
() => headerConfig.notFoundTitle || "Item Not Found",
|
||||
[headerConfig.notFoundTitle],
|
||||
);
|
||||
|
||||
const notFoundMessage = useMemo(
|
||||
() =>
|
||||
headerConfig.notFoundMessage ||
|
||||
"The item you're looking for doesn't exist or you don't have permission to view it.",
|
||||
[headerConfig.notFoundMessage],
|
||||
);
|
||||
|
||||
// Memoize entity status checks
|
||||
const isArchived = useMemo(
|
||||
() => entityData && entityData.status === 2,
|
||||
[entityData],
|
||||
);
|
||||
|
||||
const isBanned = useMemo(
|
||||
() => entityData && entityData.isBanned,
|
||||
[entityData],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() =>
|
||||
`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 ${className}`.trim(),
|
||||
[className],
|
||||
);
|
||||
|
||||
const cardContainerClasses = useMemo(
|
||||
() =>
|
||||
`bg-white border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`,
|
||||
[themeClasses.cardBorder],
|
||||
);
|
||||
|
||||
const spinnerClasses = useMemo(
|
||||
() =>
|
||||
`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`,
|
||||
[themeClasses.borderPrimary],
|
||||
);
|
||||
|
||||
// Memoize field section filters
|
||||
const fieldSectionsByType = useMemo(
|
||||
() => ({
|
||||
avatar: fieldSections.find((section) => section.type === "avatar"),
|
||||
primary: fieldSections.filter(
|
||||
(section) => section.column === "primary",
|
||||
),
|
||||
secondary: fieldSections.filter(
|
||||
(section) => section.column === "secondary",
|
||||
),
|
||||
}),
|
||||
[fieldSections],
|
||||
);
|
||||
|
||||
// Create status badge component
|
||||
const createStatusBadge = useMemo(() => {
|
||||
return (entity, statusConfig = {}) => {
|
||||
if (entity.isBanned) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 sm:px-2.5 py-0.5 rounded-full text-xs sm:text-sm font-medium ${themeClasses.badgeError}`}
|
||||
>
|
||||
<XCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
{statusConfig.bannedLabel || "Banned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (entity.status === 1) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 sm:px-2.5 py-0.5 rounded-full text-xs sm:text-sm font-medium ${themeClasses.badgePrimary}`}
|
||||
>
|
||||
<CheckCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
|
||||
{statusConfig.activeLabel || "Active"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 sm:px-2.5 py-0.5 rounded-full text-xs sm:text-sm font-medium ${themeClasses.badgeSecondary}`}
|
||||
>
|
||||
{statusConfig.inactiveLabel || "Archived"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}, [themeClasses]);
|
||||
|
||||
// Memoize loading component
|
||||
const loadingComponent = useMemo(() => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className={spinnerClasses}></div>
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base ${themeClasses.textSecondary}`}
|
||||
>
|
||||
{loadingText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [isLoading, spinnerClasses, themeClasses.textSecondary, loadingText]);
|
||||
|
||||
// Memoize breadcrumb
|
||||
const breadcrumbComponent = useMemo(() => {
|
||||
if (!breadcrumbItems || breadcrumbItems.length === 0) return null;
|
||||
return <Breadcrumb items={breadcrumbItems} />;
|
||||
}, [breadcrumbItems]);
|
||||
|
||||
// Memoize alerts
|
||||
const alertsComponent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{alerts.archived && isArchived && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={alerts.archived.message || "This item is archived"}
|
||||
icon={alerts.archived.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
{alerts.banned && isBanned && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={alerts.banned.message || "This item is banned"}
|
||||
icon={alerts.banned.icon}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
onClose={onErrorClose}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[
|
||||
alerts.archived,
|
||||
alerts.banned,
|
||||
isArchived,
|
||||
isBanned,
|
||||
error,
|
||||
onErrorClose,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize action buttons
|
||||
const actionButtonsComponent = useMemo(() => {
|
||||
if (!actionButtons || actionButtons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{actionButtons.map((button, index) =>
|
||||
button.component ? (
|
||||
<div key={index}>{button.component}</div>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
variant={button.variant}
|
||||
onClick={button.onClick}
|
||||
disabled={button.disabled}
|
||||
icon={button.icon}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [actionButtons]);
|
||||
|
||||
// Memoize tabs component
|
||||
const tabsComponent = useMemo(() => {
|
||||
if (!tabs || tabs.length === 0) return null;
|
||||
// Check if tabs have onClick handlers (callback mode) or to paths (routing mode)
|
||||
const hasOnClick = tabs.some(tab => tab.onClick);
|
||||
const mode = hasOnClick ? "callback" : "routing";
|
||||
return <Tabs tabs={tabs} mode={mode} />;
|
||||
}, [tabs]);
|
||||
|
||||
// Memoize avatar section
|
||||
const avatarSection = useMemo(() => {
|
||||
if (!fieldSectionsByType.avatar) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 order-1 xl:order-1">
|
||||
{fieldSectionsByType.avatar.component}
|
||||
</div>
|
||||
);
|
||||
}, [fieldSectionsByType.avatar]);
|
||||
|
||||
// Memoize primary column
|
||||
const primaryColumn = useMemo(() => {
|
||||
if (
|
||||
!fieldSectionsByType.primary ||
|
||||
fieldSectionsByType.primary.length === 0
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="xl:flex-1 xl:min-w-0 text-center xl:text-left">
|
||||
{fieldSectionsByType.primary.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [fieldSectionsByType.primary]);
|
||||
|
||||
// Memoize secondary column
|
||||
const secondaryColumn = useMemo(() => {
|
||||
if (
|
||||
!fieldSectionsByType.secondary ||
|
||||
fieldSectionsByType.secondary.length === 0
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="xl:flex-1 xl:min-w-0 space-y-3 sm:space-y-4 lg:space-y-6 text-center xl:text-left">
|
||||
{fieldSectionsByType.secondary.map((section, index) => (
|
||||
<div key={index} className={section.className || ""}>
|
||||
{section.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [fieldSectionsByType.secondary]);
|
||||
|
||||
// Memoize no data component
|
||||
const noDataComponent = useMemo(() => {
|
||||
if (entityData || isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-8 sm:py-16 text-center">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-12 sm:w-16 h-12 sm:h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
|
||||
>
|
||||
<UserIcon
|
||||
className={`w-6 sm:w-8 h-6 sm:h-8 ${themeClasses.textMuted}`}
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-base sm:text-lg font-medium ${themeClasses.textPrimary} mb-2`}
|
||||
>
|
||||
{notFoundTitle}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm sm:text-base ${themeClasses.textSecondary} mb-4 sm:mb-6`}
|
||||
>
|
||||
{notFoundMessage}
|
||||
</p>
|
||||
{headerConfig.notFoundAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={headerConfig.notFoundAction.onClick}
|
||||
icon={headerConfig.notFoundAction.icon}
|
||||
size="sm"
|
||||
>
|
||||
{headerConfig.notFoundAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
entityData,
|
||||
isLoading,
|
||||
themeClasses,
|
||||
notFoundTitle,
|
||||
notFoundMessage,
|
||||
headerConfig.notFoundAction,
|
||||
]);
|
||||
|
||||
// Loading state - return early
|
||||
if (isLoading) {
|
||||
return loadingComponent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbComponent}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{alertsComponent}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="shadow-sm">
|
||||
{entityData && (
|
||||
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
|
||||
{/* Header with Actions */}
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
|
||||
{headerConfig.icon && (
|
||||
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
|
||||
)}
|
||||
{headerTitle}
|
||||
</h2>
|
||||
{actionButtonsComponent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation and Content */}
|
||||
<div className={cardContainerClasses}>
|
||||
{tabsComponent}
|
||||
|
||||
{/* Entity Summary Layout */}
|
||||
<div className="py-4 sm:py-6 md:py-8 lg:py-10 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col xl:flex-row gap-4 sm:gap-6 lg:gap-8 xl:gap-12 items-center xl:items-start justify-center max-w-6xl mx-auto">
|
||||
{/* Avatar Section */}
|
||||
{avatarSection}
|
||||
|
||||
{/* Main Content Container */}
|
||||
<div className="flex-1 w-full xl:flex xl:gap-8 space-y-4 sm:space-y-6 xl:space-y-0 order-2 xl:order-2">
|
||||
{/* Primary Info Column */}
|
||||
{primaryColumn}
|
||||
|
||||
{/* Secondary Info Column */}
|
||||
{secondaryColumn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{noDataComponent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name
|
||||
DetailLiteViewInner.displayName = "DetailLiteViewInner";
|
||||
|
||||
// Main wrapper component that provides theme context
|
||||
const DetailLiteView = memo(
|
||||
({
|
||||
// Core data
|
||||
entityData = null,
|
||||
breadcrumbItems = [],
|
||||
headerConfig = {},
|
||||
fieldSections = [],
|
||||
actionButtons = [],
|
||||
tabs = [],
|
||||
|
||||
// Alerts and status
|
||||
alerts = {},
|
||||
|
||||
// Event handlers
|
||||
onUnauthorized = () => {},
|
||||
|
||||
// State
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onErrorClose = () => {},
|
||||
|
||||
// Styling
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<DetailLiteViewInner
|
||||
entityData={entityData}
|
||||
breadcrumbItems={breadcrumbItems}
|
||||
headerConfig={headerConfig}
|
||||
fieldSections={fieldSections}
|
||||
actionButtons={actionButtons}
|
||||
tabs={tabs}
|
||||
alerts={alerts}
|
||||
onUnauthorized={onUnauthorized}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onErrorClose={onErrorClose}
|
||||
className={className}
|
||||
/>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name
|
||||
DetailLiteView.displayName = "DetailLiteView";
|
||||
|
||||
export default DetailLiteView;
|
||||
|
||||
// Export helper function for reuse in other components
|
||||
export { DetailLiteView };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as DetailLiteView } from './DetailLiteView.jsx';
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
// File Path: web/frontend/src/components/UIX/DetailPageIcon/DetailPageIcon.jsx
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Move constants outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
md: {
|
||||
container: "p-2.5",
|
||||
icon: "h-6 w-6 sm:h-8 sm:w-8",
|
||||
},
|
||||
lg: {
|
||||
container: "p-3",
|
||||
icon: "h-8 w-8 sm:h-10 sm:w-10",
|
||||
},
|
||||
xl: {
|
||||
container: "p-4",
|
||||
icon: "h-10 w-10 sm:h-12 sm:w-12",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* DetailPageIcon Component
|
||||
* Theme-aware icon display for detail page headers with gradient background
|
||||
* Provides consistent styling for page title icons in detail views
|
||||
*
|
||||
* @param {React.ComponentType} icon - Heroicon component to display
|
||||
* @param {string} className - Additional CSS classes for the container
|
||||
* @param {string} size - Icon size (md, lg, xl)
|
||||
* @param {boolean} gradient - Whether to use gradient background (default: true)
|
||||
*/
|
||||
const DetailPageIcon = memo(
|
||||
({ icon: Icon, className = "", size = "lg", gradient = true }) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Early return if no icon provided
|
||||
if (!Icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Get size classes from constant
|
||||
const sizeConfig = SIZE_CLASSES[size] || SIZE_CLASSES.lg;
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() =>
|
||||
`
|
||||
${sizeConfig.container}
|
||||
rounded-2xl shadow-lg mr-4 flex-shrink-0
|
||||
${themeClasses.bgGradientSecondary}
|
||||
${className}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
[sizeConfig.container, themeClasses.bgGradientSecondary, className],
|
||||
);
|
||||
|
||||
// Memoize icon classes - white icon on themed gradient background
|
||||
const iconClasses = useMemo(
|
||||
() => `${sizeConfig.icon} text-white`,
|
||||
[sizeConfig.icon],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<Icon className={iconClasses} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
DetailPageIcon.displayName = "DetailPageIcon";
|
||||
|
||||
export default DetailPageIcon;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// File: src/components/UI/Divider/Divider.jsx
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* Divider Component
|
||||
* Visual separator between content sections
|
||||
*
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {string} text - Optional text to display in divider
|
||||
*/
|
||||
const Divider = memo(({ className = "", text = "" }) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
inputBorder: getThemeClasses("input-border"),
|
||||
bgCard: getThemeClasses("bg-card"),
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() => `relative ${className}`.trim(),
|
||||
[className],
|
||||
);
|
||||
|
||||
// Memoize divider line classes
|
||||
const lineClasses = useMemo(
|
||||
() => `w-full border-t ${themeClasses.inputBorder}`,
|
||||
[themeClasses.inputBorder],
|
||||
);
|
||||
|
||||
// Memoize text span classes
|
||||
const textClasses = useMemo(
|
||||
() => `px-2 ${themeClasses.bgCard} ${themeClasses.textMuted}`,
|
||||
[themeClasses.bgCard, themeClasses.textMuted],
|
||||
);
|
||||
|
||||
// Memoize the text container
|
||||
const textContainer = useMemo(() => {
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className={textClasses}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}, [text, textClasses]);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className={lineClasses} />
|
||||
</div>
|
||||
{textContainer}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display name for better debugging
|
||||
Divider.displayName = "Divider";
|
||||
|
||||
export default Divider;
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// File Path: web/frontend/src/components/UIX/EditButton/EditButton.jsx
|
||||
|
||||
import React, { useMemo, useCallback, memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Move constants outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: "px-3 py-2 text-xs sm:text-sm",
|
||||
md: "px-5 py-3 text-sm sm:text-base",
|
||||
lg: "px-6 py-4 text-base sm:text-lg",
|
||||
};
|
||||
|
||||
const ICON_SIZE_CLASSES = {
|
||||
sm: "h-3 w-3 sm:h-4 sm:w-4",
|
||||
md: "h-4 w-4 sm:h-5 sm:w-5",
|
||||
lg: "h-5 w-5 sm:h-6 sm:w-6",
|
||||
};
|
||||
|
||||
/**
|
||||
* EditButton Component
|
||||
* Standardized button for editing actions
|
||||
* Uses blue theme and consistent styling
|
||||
*
|
||||
* @param {Function} onClick - Click handler function
|
||||
* @param {string} to - Navigation path (for Link)
|
||||
* @param {string} text - Button text (default: "Edit")
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} disabled - Whether button is disabled
|
||||
* @param {React.ComponentType} icon - Icon component (defaults to PencilSquareIcon)
|
||||
* @param {string} size - Button size (sm, md, lg)
|
||||
* @param {string} variant - Button variant (default, primary)
|
||||
*/
|
||||
const EditButton = memo(
|
||||
({
|
||||
onClick,
|
||||
to,
|
||||
text = "Edit",
|
||||
className = "",
|
||||
disabled = false,
|
||||
icon: Icon = PencilSquareIcon,
|
||||
size = "md",
|
||||
variant = "default",
|
||||
...props
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
buttonOutline:
|
||||
variant === "primary" ? getThemeClasses("button-outline") : null,
|
||||
buttonSecondary:
|
||||
variant !== "primary" ? getThemeClasses("button-secondary") : null,
|
||||
}),
|
||||
[getThemeClasses, variant],
|
||||
);
|
||||
|
||||
// Get the active variant class
|
||||
const variantClass = useMemo(
|
||||
() =>
|
||||
variant === "primary"
|
||||
? themeClasses.buttonOutline
|
||||
: themeClasses.buttonSecondary,
|
||||
[variant, themeClasses.buttonOutline, themeClasses.buttonSecondary],
|
||||
);
|
||||
|
||||
// Get size classes from constants
|
||||
const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.md;
|
||||
const iconSizeClass = ICON_SIZE_CLASSES[size] || ICON_SIZE_CLASSES.md;
|
||||
|
||||
// Memoize title
|
||||
const title = useMemo(() => `${text} Item`, [text]);
|
||||
|
||||
// Memoize base classes
|
||||
const baseClasses = useMemo(
|
||||
() =>
|
||||
`
|
||||
inline-flex items-center
|
||||
${sizeClass}
|
||||
rounded-xl shadow-sm font-medium
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
transition-all duration-200
|
||||
${variantClass}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
|
||||
${className}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
[sizeClass, variantClass, disabled, className],
|
||||
);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(() => `${iconSizeClass} mr-2`, [iconSizeClass]);
|
||||
|
||||
// Memoize content
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon && <Icon className={iconClasses} />}
|
||||
{text}
|
||||
</>
|
||||
),
|
||||
[Icon, iconClasses, text],
|
||||
);
|
||||
|
||||
// Memoize click handler for Link
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
if (onClick && !disabled) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[onClick, disabled],
|
||||
);
|
||||
|
||||
// Render as Link if 'to' prop is provided and not disabled
|
||||
if (to && !disabled) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={baseClasses}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Render as button
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={baseClasses}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
EditButton.displayName = "EditButton";
|
||||
|
||||
export default EditButton;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// File: src/components/UI/EmptyState/EmptyState.jsx
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
/**
|
||||
* EmptyState Component
|
||||
* Placeholder for when no data is available
|
||||
*
|
||||
* @param {string} title - Main empty state message
|
||||
* @param {string} description - Additional description
|
||||
* @param {React.Component} icon - Icon component to display
|
||||
* @param {React.ReactNode} action - Call-to-action element
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const EmptyState = memo(
|
||||
({
|
||||
title = "No data found",
|
||||
description = "",
|
||||
icon: Icon,
|
||||
action,
|
||||
className = "",
|
||||
}) => {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoize theme classes
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
textMuted: getThemeClasses("text-muted"),
|
||||
textPrimary: getThemeClasses("text-primary"),
|
||||
}),
|
||||
[getThemeClasses],
|
||||
);
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() => `text-center py-12 ${className}`.trim(),
|
||||
[className],
|
||||
);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(
|
||||
() => `mx-auto h-12 w-12 ${themeClasses.textMuted}`,
|
||||
[themeClasses.textMuted],
|
||||
);
|
||||
|
||||
// Memoize title classes
|
||||
const titleClasses = useMemo(
|
||||
() => `mt-2 text-sm font-medium ${themeClasses.textPrimary}`,
|
||||
[themeClasses.textPrimary],
|
||||
);
|
||||
|
||||
// Memoize description classes
|
||||
const descriptionClasses = useMemo(
|
||||
() => `mt-1 text-sm ${themeClasses.textMuted}`,
|
||||
[themeClasses.textMuted],
|
||||
);
|
||||
|
||||
// Memoize icon element
|
||||
const iconElement = useMemo(() => {
|
||||
if (!Icon) return null;
|
||||
return <Icon className={iconClasses} />;
|
||||
}, [Icon, iconClasses]);
|
||||
|
||||
// Memoize description element
|
||||
const descriptionElement = useMemo(() => {
|
||||
if (!description) return null;
|
||||
return <p className={descriptionClasses}>{description}</p>;
|
||||
}, [description, descriptionClasses]);
|
||||
|
||||
// Memoize action element
|
||||
const actionElement = useMemo(() => {
|
||||
if (!action) return null;
|
||||
return <div className="mt-6">{action}</div>;
|
||||
}, [action]);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{iconElement}
|
||||
<h3 className={titleClasses}>{title}</h3>
|
||||
{descriptionElement}
|
||||
{actionElement}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
EmptyState.displayName = "EmptyState";
|
||||
|
||||
export default EmptyState;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// File Path: web/frontend/src/components/UIX/EmptyStateIcon/EmptyStateIcon.jsx
|
||||
|
||||
import React, { useMemo, memo } from "react";
|
||||
|
||||
// Move constants outside component to prevent recreation
|
||||
const SIZE_CLASSES = {
|
||||
sm: {
|
||||
container: "p-2",
|
||||
icon: "h-8 w-8",
|
||||
},
|
||||
md: {
|
||||
container: "p-2.5",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
lg: {
|
||||
container: "p-3",
|
||||
icon: "h-12 w-12",
|
||||
},
|
||||
xl: {
|
||||
container: "p-4",
|
||||
icon: "h-16 w-16",
|
||||
},
|
||||
};
|
||||
|
||||
// Static style objects
|
||||
const BACKGROUND_STYLES = {
|
||||
solid: { backgroundColor: "#172554" }, // blue-950
|
||||
gradient: { background: "linear-gradient(135deg, #172554 0%, #1e3a8a 100%)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* EmptyStateIcon Component
|
||||
* Blue-themed icon display for empty states with gradient background
|
||||
* Provides consistent styling for "no items found" scenarios
|
||||
*
|
||||
* @param {React.ComponentType} icon - Heroicon component to display
|
||||
* @param {string} className - Additional CSS classes for the container
|
||||
* @param {string} size - Icon size (sm, md, lg, xl)
|
||||
* @param {boolean} gradient - Whether to use gradient background (default: true)
|
||||
*/
|
||||
const EmptyStateIcon = memo(
|
||||
({ icon: Icon, className = "", size = "lg", gradient = true, ...props }) => {
|
||||
// Early return if no icon provided
|
||||
if (!Icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get size configuration from constant
|
||||
const sizeConfig = SIZE_CLASSES[size] || SIZE_CLASSES.lg;
|
||||
|
||||
// Memoize container classes
|
||||
const containerClasses = useMemo(
|
||||
() =>
|
||||
`${sizeConfig.container} rounded-2xl shadow-lg mx-auto w-fit mb-4 ${className}`.trim(),
|
||||
[sizeConfig.container, className],
|
||||
);
|
||||
|
||||
// Memoize icon classes
|
||||
const iconClasses = useMemo(
|
||||
() => `${sizeConfig.icon} text-white`,
|
||||
[sizeConfig.icon],
|
||||
);
|
||||
|
||||
// Memoize background style
|
||||
const backgroundStyle = useMemo(
|
||||
() => (gradient ? BACKGROUND_STYLES.gradient : BACKGROUND_STYLES.solid),
|
||||
[gradient],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses} style={backgroundStyle} {...props}>
|
||||
<Icon className={iconClasses} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Add display name for better debugging
|
||||
EmptyStateIcon.displayName = "EmptyStateIcon";
|
||||
|
||||
export default EmptyStateIcon;
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
// File: src/components/UIX/EntityActionConfirmationPage/EntityActionConfirmationPage.jsx
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
XMarkIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
import { useAuth } from "../../../services/Services";
|
||||
|
||||
/**
|
||||
* EntityActionConfirmationPage
|
||||
*
|
||||
* A reusable component for entity action confirmation pages (archive, delete, ban, etc.)
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.entityType - Type of entity (e.g., 'customer', 'speaker', 'facilitator')
|
||||
* @param {string} props.entityId - ID of the entity
|
||||
* @param {string} props.actionType - Type of action ('archive', 'unarchive', 'delete', 'ban', 'unban', 'upgrade', 'downgrade')
|
||||
* @param {Function} props.fetchEntity - Function to fetch entity data: (id, onSuccess, onError, onDone, onUnauthorized) => void
|
||||
* @param {Function} props.executeAction - Function to execute the action: (id, onSuccess, onError, onDone, onUnauthorized) => void
|
||||
* @param {Array} props.breadcrumbItems - Breadcrumb navigation items
|
||||
* @param {Object} props.pageConfig - Page configuration (title, subtitle, icon, etc.)
|
||||
* @param {Function} props.renderEntityInfo - Function to render entity information: (entity) => ReactNode
|
||||
* @param {Object} props.warningConfig - Warning message configuration
|
||||
* @param {Array} props.statusAlerts - Array of status alert configurations: [{condition: (entity) => boolean, type: 'info'|'warning'|'error', message: string, icon: Component}]
|
||||
* @param {Function} props.isActionDisabled - Function to check if action should be disabled: (entity) => boolean
|
||||
* @param {string} props.returnPath - Path to navigate after action
|
||||
* @param {string} props.successRedirectPath - Path to redirect after successful action (defaults to returnPath)
|
||||
* @param {number} props.successRedirectDelay - Delay before redirect in ms (default: 2000)
|
||||
*/
|
||||
function EntityActionConfirmationPage({
|
||||
entityType = "entity",
|
||||
entityId,
|
||||
actionType = "action",
|
||||
fetchEntity,
|
||||
executeAction,
|
||||
breadcrumbItems = [],
|
||||
pageConfig = {},
|
||||
renderEntityInfo,
|
||||
warningConfig = {},
|
||||
statusAlerts = [],
|
||||
isActionDisabled,
|
||||
returnPath,
|
||||
successRedirectPath,
|
||||
successRedirectDelay = 2000,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { authManager } = useAuth();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Component states
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isFetching, setFetching] = useState(false);
|
||||
const [entity, setEntity] = useState(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
// Default page config
|
||||
const {
|
||||
title = `${entityType} - ${actionType}`,
|
||||
subtitle = `${actionType} ${entityType}`,
|
||||
icon: PageIcon,
|
||||
actionIcon: ActionIcon,
|
||||
loadingText = `Loading ${entityType} details...`,
|
||||
} = pageConfig;
|
||||
|
||||
// Default warning config
|
||||
const {
|
||||
title: warningTitle = `${actionType} ${entityType} - Are you sure?`,
|
||||
description = `You are about to ${actionType} this ${entityType}.`,
|
||||
consequences = [],
|
||||
confirmationText = "Are you sure you would like to continue?",
|
||||
warningType = "amber", // 'amber', 'red', 'yellow'
|
||||
} = warningConfig;
|
||||
|
||||
// Unauthorized callback
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Load entity details
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchEntityData = async () => {
|
||||
if (!authManager.isAuthenticated()) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
setErrors({ general: `${entityType} ID is required` });
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await fetchEntity(
|
||||
entityId,
|
||||
(entityData) => {
|
||||
if (mounted) {
|
||||
setEntity(entityData);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (mounted) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`Failed to fetch ${entityType}:`, error);
|
||||
}
|
||||
setErrors(error);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (mounted) {
|
||||
setFetching(false);
|
||||
}
|
||||
},
|
||||
onUnauthorized,
|
||||
);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`Failed to fetch ${entityType}:`, error);
|
||||
}
|
||||
setErrors({ general: `Failed to load ${entityType} information` });
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntityData();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [entityId, fetchEntity, authManager, navigate, onUnauthorized, entityType]);
|
||||
|
||||
// Handle action confirmation
|
||||
const handleConfirmAction = useCallback(async () => {
|
||||
setShowConfirmModal(false);
|
||||
setErrors({});
|
||||
setFetching(true);
|
||||
|
||||
try {
|
||||
await executeAction(
|
||||
entityId,
|
||||
() => {
|
||||
// Success callback
|
||||
const capitalizedAction = actionType.charAt(0).toUpperCase() + actionType.slice(1);
|
||||
setSuccessMessage(`${entityType} has been successfully ${actionType}d`);
|
||||
|
||||
// Navigate after delay
|
||||
setTimeout(() => {
|
||||
navigate(successRedirectPath || returnPath);
|
||||
}, successRedirectDelay);
|
||||
},
|
||||
(error) => {
|
||||
// Error callback
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`Failed to ${actionType} ${entityType}:`, error);
|
||||
}
|
||||
setErrors(error);
|
||||
setFetching(false);
|
||||
},
|
||||
() => {
|
||||
// Done callback
|
||||
if (!successMessage) {
|
||||
setFetching(false);
|
||||
}
|
||||
},
|
||||
onUnauthorized,
|
||||
);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`Failed to ${actionType} ${entityType}:`, error);
|
||||
}
|
||||
setErrors({ general: `Failed to ${actionType} ${entityType}` });
|
||||
setFetching(false);
|
||||
}
|
||||
}, [
|
||||
entityId,
|
||||
executeAction,
|
||||
actionType,
|
||||
entityType,
|
||||
navigate,
|
||||
onUnauthorized,
|
||||
successMessage,
|
||||
returnPath,
|
||||
successRedirectPath,
|
||||
successRedirectDelay,
|
||||
]);
|
||||
|
||||
// Get warning color classes
|
||||
const getWarningClasses = () => {
|
||||
switch (warningType) {
|
||||
case "red":
|
||||
return {
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-200",
|
||||
iconText: "text-red-600",
|
||||
titleText: "text-red-900",
|
||||
descText: "text-red-800",
|
||||
listText: "text-red-700",
|
||||
bulletBg: "bg-red-600",
|
||||
confirmText: "text-red-900",
|
||||
};
|
||||
case "yellow":
|
||||
return {
|
||||
bg: "bg-yellow-50",
|
||||
border: "border-yellow-200",
|
||||
iconText: "text-yellow-600",
|
||||
titleText: "text-yellow-900",
|
||||
descText: "text-yellow-800",
|
||||
listText: "text-yellow-700",
|
||||
bulletBg: "bg-yellow-600",
|
||||
confirmText: "text-yellow-900",
|
||||
};
|
||||
case "amber":
|
||||
default:
|
||||
return {
|
||||
bg: "bg-amber-50",
|
||||
border: "border-amber-200",
|
||||
iconText: "text-amber-600",
|
||||
titleText: "text-amber-900",
|
||||
descText: "text-amber-800",
|
||||
listText: "text-amber-700",
|
||||
bulletBg: "bg-amber-600",
|
||||
confirmText: "text-amber-900",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const warningClasses = getWarningClasses();
|
||||
|
||||
// Render loading state
|
||||
if (isFetching && !entity) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<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 ${getThemeClasses("text-secondary")}`}>
|
||||
{loadingText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionDisabled = isActionDisabled ? isActionDisabled(entity) : false;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbItems.length > 0 && (
|
||||
<nav className="flex mb-6" aria-label="Breadcrumb">
|
||||
<ol className="inline-flex items-center space-x-1 md:space-x-3">
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={item.isActive ? "inline-flex items-center" : ""}
|
||||
aria-current={item.isActive ? "page" : undefined}
|
||||
>
|
||||
{index > 0 && (
|
||||
<span className={`mx-2 ${getThemeClasses("text-muted")}`}>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{item.to && !item.isActive ? (
|
||||
<Link
|
||||
to={item.to}
|
||||
className={`inline-flex items-center text-sm font-medium ${getThemeClasses("text-primary")} hover:text-blue-600`}
|
||||
>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={`text-sm font-medium ${getThemeClasses("text-muted")} inline-flex items-center`}
|
||||
>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className={`text-2xl md:text-3xl font-bold ${getThemeClasses("text-primary")} flex items-center`}>
|
||||
{PageIcon && (
|
||||
<PageIcon className="w-6 h-6 md:w-8 md:h-8 mr-3 text-blue-600" />
|
||||
)}
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")} flex items-center`}>
|
||||
{ActionIcon && <ActionIcon className="w-4 h-4 mr-1" />}
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg flex items-center">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Messages */}
|
||||
{errors && Object.keys(errors).length > 0 && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="flex items-center">
|
||||
<XMarkIcon className="w-5 h-5 mr-2" />
|
||||
{errors.general ||
|
||||
errors.message ||
|
||||
errors.detail ||
|
||||
"An error occurred. Please try again."}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setErrors({})}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{statusAlerts.map((alert, index) => {
|
||||
if (!alert.condition || !alert.condition(entity)) return null;
|
||||
|
||||
const alertColors = {
|
||||
info: { bg: "bg-blue-50", border: "border-blue-200", text: "text-blue-700" },
|
||||
warning: { bg: "bg-amber-50", border: "border-amber-200", text: "text-amber-700" },
|
||||
error: { bg: "bg-red-50", border: "border-red-200", text: "text-red-700" },
|
||||
};
|
||||
|
||||
const colors = alertColors[alert.type] || alertColors.info;
|
||||
const AlertIcon = alert.icon || InformationCircleIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`mb-4 ${colors.bg} border ${colors.border} ${colors.text} px-4 py-3 rounded-lg flex items-center`}
|
||||
>
|
||||
<AlertIcon className="w-5 h-5 mr-2" />
|
||||
{alert.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={`${getThemeClasses("bg-card")} shadow-sm rounded-lg overflow-hidden`}>
|
||||
<div className="p-6">
|
||||
{/* Warning Message */}
|
||||
<div className={`mb-6 ${warningClasses.bg} border ${warningClasses.border} rounded-lg p-6`}>
|
||||
<div className="flex items-start">
|
||||
<ExclamationTriangleIcon
|
||||
className={`w-6 h-6 ${warningClasses.iconText} mt-1 mr-3 flex-shrink-0`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-lg font-semibold ${warningClasses.titleText} mb-2`}>
|
||||
{warningTitle}
|
||||
</h3>
|
||||
<p className={`${warningClasses.descText} mb-3`}>
|
||||
{description}
|
||||
</p>
|
||||
{consequences.length > 0 && (
|
||||
<ul className={`space-y-2 ${warningClasses.listText} ml-4`}>
|
||||
{consequences.map((consequence, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 ${warningClasses.bulletBg} rounded-full mt-1.5 mr-2 flex-shrink-0`}
|
||||
></span>
|
||||
{consequence}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className={`mt-4 font-semibold ${warningClasses.confirmText}`}>
|
||||
{confirmationText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Information */}
|
||||
{entity && renderEntityInfo && renderEntityInfo(entity)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||
<Link to={returnPath}>
|
||||
<button
|
||||
disabled={isFetching}
|
||||
className={`w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border ${getThemeClasses("border-border")} rounded-lg text-sm font-medium ${getThemeClasses("text-secondary")} ${getThemeClasses("bg-card")} hover:bg-opacity-80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to More
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={isFetching || actionDisabled}
|
||||
className={`w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border rounded-lg text-sm font-medium transition-colors ${
|
||||
actionDisabled
|
||||
? `${getThemeClasses("border-border")} ${getThemeClasses("text-muted")} ${getThemeClasses("bg-card")} cursor-not-allowed`
|
||||
: "border-red-300 text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{ActionIcon && <ActionIcon className="w-4 h-4 mr-2" />}
|
||||
{isFetching
|
||||
? "Processing..."
|
||||
: actionDisabled
|
||||
? `Already ${actionType}d`
|
||||
: `Confirm and ${actionType}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
></div>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className={`text-lg font-semibold leading-6 ${getThemeClasses("text-primary")}`}>
|
||||
Confirm {actionType.charAt(0).toUpperCase() + actionType.slice(1)}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
<strong>Final Confirmation</strong>
|
||||
</p>
|
||||
<p className={`mt-2 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
{description}
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-medium text-red-600">
|
||||
Are you absolutely sure you want to proceed?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${getThemeClasses("bg-muted")} px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmAction}
|
||||
disabled={isFetching}
|
||||
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetching ? `${actionType}ing...` : `Yes, ${actionType}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
disabled={isFetching}
|
||||
className={`mt-3 inline-flex w-full justify-center rounded-md ${getThemeClasses("bg-card")} px-3 py-2 text-sm font-semibold ${getThemeClasses("text-primary")} shadow-sm ring-1 ring-inset ${getThemeClasses("border-border")} hover:bg-opacity-80 sm:mt-0 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityActionConfirmationPage;
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
// File: src/components/UIX/EntityAttachmentAddPage/EntityAttachmentAddPage.jsx
|
||||
// Reusable entity attachment add/upload page component
|
||||
//
|
||||
// This component provides a complete page layout for uploading attachments
|
||||
// to any entity type (staff, customer, organization, etc.) with validation,
|
||||
// progress tracking, and error handling.
|
||||
//
|
||||
// Usage Example:
|
||||
// <EntityAttachmentAddPage
|
||||
// config={{
|
||||
// entityId: "123",
|
||||
// entityType: "staff member",
|
||||
// ownershipType: ATTACHMENT_OWNERSHIP_TYPE.STAFF,
|
||||
// fetchEntity: async (id, onUnauthorized) => {...},
|
||||
// uploadAttachment: async (file, metadata, onProgress, onUnauthorized) => {...},
|
||||
// breadcrumbs: { items: [...] },
|
||||
// header: { title: "Add Attachment", icon: DocumentArrowUpIcon },
|
||||
// routes: { backPath: "/admin/staff/123/attachments" },
|
||||
// }}
|
||||
// />
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
XMarkIcon,
|
||||
DocumentArrowUpIcon,
|
||||
ArchiveBoxIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Breadcrumb,
|
||||
useUIXTheme,
|
||||
BackButton,
|
||||
CreateButton,
|
||||
} from "../";
|
||||
|
||||
// Development-only logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
const log = (...args) => DEBUG && console.log(...args);
|
||||
const error = (...args) => console.error(...args);
|
||||
|
||||
// Maximum file size (50MB)
|
||||
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* EntityAttachmentAddPage Component
|
||||
*
|
||||
* A reusable whole-page component for uploading attachments to entities.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.config - Configuration object containing all settings
|
||||
*
|
||||
* Config Structure:
|
||||
* {
|
||||
* // Core settings
|
||||
* entityId: string, // Entity ID
|
||||
* entityType: string, // Entity type for display (e.g., "staff member")
|
||||
* ownershipType: number, // ATTACHMENT_OWNERSHIP_TYPE constant
|
||||
* maxFileSize: number, // Optional: Max file size in bytes (default: 50MB)
|
||||
*
|
||||
* // Data fetching functions
|
||||
* fetchEntity: async (entityId, onUnauthorized) => entity,
|
||||
* uploadAttachment: async (file, metadata, onProgress, onUnauthorized) => response,
|
||||
*
|
||||
* // Navigation configuration
|
||||
* breadcrumbs: {
|
||||
* items: [{ label, to, icon, isActive }], // Or function: (entity, entityId) => items
|
||||
* },
|
||||
*
|
||||
* // Header configuration
|
||||
* header: {
|
||||
* title: string, // Page title (default: "Add Attachment")
|
||||
* icon: Component, // Icon component (default: DocumentArrowUpIcon)
|
||||
* },
|
||||
*
|
||||
* // Routes configuration
|
||||
* routes: {
|
||||
* backPath: string, // Path for back button
|
||||
* backLabel: string, // Optional: Label for back button (default: "Back")
|
||||
* successPath: string, // Optional: Path to navigate to on success
|
||||
* },
|
||||
*
|
||||
* // Display configuration (optional)
|
||||
* showEntityStatus: boolean, // Show archived/status alerts (default: true)
|
||||
* canUpload: (entity) => boolean, // Optional: Check if user can upload
|
||||
* }
|
||||
*/
|
||||
const EntityAttachmentAddPageContent = memo(
|
||||
function EntityAttachmentAddPageContent({ config }) {
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Validate required config
|
||||
if (!config) {
|
||||
error("EntityAttachmentAddPage: config is required");
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
entityId,
|
||||
entityType = "entity",
|
||||
ownershipType,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
fetchEntity,
|
||||
uploadAttachment,
|
||||
breadcrumbs,
|
||||
header,
|
||||
routes,
|
||||
showEntityStatus = true,
|
||||
canUpload,
|
||||
} = config;
|
||||
|
||||
// Component states
|
||||
const [entity, setEntity] = useState(null);
|
||||
const [isFetching, setFetching] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
const [alertType, setAlertType] = useState("");
|
||||
|
||||
// Use refs to track component lifecycle
|
||||
const isMounted = useRef(true);
|
||||
const isFetchingRef = useRef(false);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Reset isMounted on every render (handles React Strict Mode remounts)
|
||||
isMounted.current = true;
|
||||
|
||||
// Handle unauthorized access
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Fetch entity data
|
||||
const fetchEntityData = useCallback(async () => {
|
||||
if (!fetchEntity || !entityId) return;
|
||||
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
setFetching(true);
|
||||
log("[EntityAttachmentAddPage] Fetching entity data for ID:", entityId);
|
||||
const entityData = await fetchEntity(entityId, onUnauthorized);
|
||||
log("[EntityAttachmentAddPage] Entity data received:", entityData);
|
||||
if (isMounted.current) {
|
||||
setEntity(entityData);
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't treat abort as an error
|
||||
if (err.name === "AbortError") {
|
||||
log("[EntityAttachmentAddPage] Request aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
error("[EntityAttachmentAddPage] Error fetching entity:", err);
|
||||
if (isMounted.current) {
|
||||
setErrors({ general: "Failed to load entity details" });
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}, [entityId, fetchEntity, onUnauthorized]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
// Skip if no entityId
|
||||
if (!entityId) {
|
||||
log("[EntityAttachmentAddPage] No entityId, skipping load");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if currently fetching (prevents double-fetch in React Strict Mode)
|
||||
if (isFetchingRef.current) {
|
||||
log("[EntityAttachmentAddPage] Already fetching (ref check), skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
// Set ref immediately to prevent double-fetch
|
||||
isFetchingRef.current = true;
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
if (fetchEntity) {
|
||||
await fetchEntityData();
|
||||
}
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentAddPage] Error loading initial data:", err);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entityId]);
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = useCallback((bytes) => {
|
||||
if (!bytes) return "0 Bytes";
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
}, []);
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = useCallback(
|
||||
(event) => {
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file) {
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
setErrors({
|
||||
file: `File size must be less than ${maxFileSize / (1024 * 1024)}MB`,
|
||||
});
|
||||
setSelectedFile(null);
|
||||
event.target.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setErrors({});
|
||||
}
|
||||
},
|
||||
[maxFileSize]
|
||||
);
|
||||
|
||||
// Handle file removal
|
||||
const handleFileRemove = useCallback(() => {
|
||||
setSelectedFile(null);
|
||||
const fileInput = document.getElementById("file-upload");
|
||||
if (fileInput) {
|
||||
fileInput.value = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!uploadAttachment) {
|
||||
error("[EntityAttachmentAddPage] uploadAttachment function is required");
|
||||
return;
|
||||
}
|
||||
|
||||
log("[EntityAttachmentAddPage] Starting upload...");
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!title || !title.trim()) {
|
||||
setErrors({ title: "Title is required" });
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
setErrors({ file: "File is required" });
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare metadata
|
||||
const metadata = {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
entityType: String(ownershipType),
|
||||
entityId: entityId,
|
||||
};
|
||||
|
||||
log("[EntityAttachmentAddPage] Uploading with metadata:", metadata);
|
||||
|
||||
// Upload attachment
|
||||
const result = await uploadAttachment(
|
||||
selectedFile,
|
||||
metadata,
|
||||
(progress) => {
|
||||
if (isMounted.current) {
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
},
|
||||
onUnauthorized
|
||||
);
|
||||
|
||||
log("[EntityAttachmentAddPage] Upload successful:", result);
|
||||
|
||||
if (isMounted.current) {
|
||||
setAlertMessage("Attachment uploaded successfully!");
|
||||
setAlertType("success");
|
||||
|
||||
// Navigate to success path if provided
|
||||
if (routes?.successPath) {
|
||||
setTimeout(() => {
|
||||
navigate(routes.successPath);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentAddPage] Upload failed:", err);
|
||||
if (isMounted.current) {
|
||||
setErrors(err);
|
||||
setAlertMessage("Failed to upload attachment");
|
||||
setAlertType("error");
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setFetching(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
uploadAttachment,
|
||||
title,
|
||||
description,
|
||||
selectedFile,
|
||||
ownershipType,
|
||||
entityId,
|
||||
onUnauthorized,
|
||||
routes,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Build breadcrumb items
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
if (!breadcrumbs) return [];
|
||||
|
||||
if (typeof breadcrumbs.items === "function") {
|
||||
return breadcrumbs.items(entity, entityId);
|
||||
}
|
||||
|
||||
return breadcrumbs.items || [];
|
||||
}, [breadcrumbs, entity, entityId]);
|
||||
|
||||
// Check if upload is allowed
|
||||
const uploadAllowed = useMemo(() => {
|
||||
if (!canUpload) return true;
|
||||
return entity ? canUpload(entity) : false;
|
||||
}, [canUpload, entity]);
|
||||
|
||||
// Header configuration
|
||||
const pageTitle = header?.title || "Add Attachment";
|
||||
const PageIcon = header?.icon || DocumentArrowUpIcon;
|
||||
const backPath = routes?.backPath || "";
|
||||
const backLabel = routes?.backLabel || "Back";
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbItems.length > 0 && (
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
)}
|
||||
|
||||
{/* Status Alerts */}
|
||||
{showEntityStatus && entity && entity.status === 2 && (
|
||||
<div className={`mb-4 px-4 py-3 rounded-lg flex items-center ${getThemeClasses("alert-info")}`}>
|
||||
<ArchiveBoxIcon className="w-5 h-5 mr-2" />
|
||||
This {entityType.toLowerCase()} is archived
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert Messages */}
|
||||
{alertMessage && (
|
||||
<div
|
||||
className={`mb-4 px-4 py-3 rounded-lg flex items-center justify-between ${
|
||||
alertType === "success"
|
||||
? getThemeClasses("alert-success")
|
||||
: getThemeClasses("alert-error")
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{alertType === "success" ? (
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="w-5 h-5 mr-2" />
|
||||
)}
|
||||
<span>{alertMessage}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAlertMessage("")}
|
||||
className="ml-4 hover:bg-white hover:bg-opacity-20 rounded p-1"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Card */}
|
||||
<div className={`${getThemeClasses("bg-card")} shadow-sm rounded-lg`}>
|
||||
<div
|
||||
className={`px-6 py-5 ${getThemeClasses("bg-gradient-secondary")} rounded-t-lg`}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-white flex items-center">
|
||||
<PageIcon className="w-8 h-8 mr-3 text-white/80" />
|
||||
{pageTitle}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{isFetching && uploadProgress === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-10 w-10 border-b-2 ${getThemeClasses("loading-spinner")} mx-auto`}
|
||||
></div>
|
||||
<p className={`mt-4 ${getThemeClasses("text-secondary")}`}>
|
||||
Processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Error Display */}
|
||||
{errors.general && (
|
||||
<div className={`mb-6 px-4 py-3 rounded-lg ${getThemeClasses("alert-error")}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ExclamationCircleIcon className="w-5 h-5 mr-2" />
|
||||
<span>{errors.general}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setErrors({})}
|
||||
className="ml-4 hover:opacity-70"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-6">
|
||||
{/* Title Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="title"
|
||||
className={`block text-base font-medium ${getThemeClasses("text-primary")} mb-2`}
|
||||
>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter attachment title"
|
||||
maxLength={255}
|
||||
disabled={isFetching}
|
||||
className={`block w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none ${getThemeClasses("input-focus-ring")} ${
|
||||
errors.title
|
||||
? `${getThemeClasses("input-border-error")} text-red-900 placeholder-red-300`
|
||||
: getThemeClasses("input-border")
|
||||
} text-base`}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{errors.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className={`block text-base font-medium ${getThemeClasses("text-primary")} mb-2`}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="description"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter attachment description (optional)"
|
||||
maxLength={1000}
|
||||
disabled={isFetching}
|
||||
className={`block w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none ${getThemeClasses("input-focus-ring")} ${
|
||||
errors.description
|
||||
? `${getThemeClasses("input-border-error")} text-red-900 placeholder-red-300`
|
||||
: getThemeClasses("input-border")
|
||||
} text-base`}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<div className={`block text-base font-medium ${getThemeClasses("text-primary")} mb-2`}>
|
||||
File <span className="text-red-500">*</span>
|
||||
</div>
|
||||
{selectedFile ? (
|
||||
<div className={`px-4 py-3 rounded-lg ${getThemeClasses("alert-success")}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
<span className="font-medium">
|
||||
File ready to upload
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>
|
||||
<strong>Name:</strong> {selectedFile.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Size:</strong>{" "}
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong>{" "}
|
||||
{selectedFile.type || "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileRemove}
|
||||
disabled={isFetching}
|
||||
className={`ml-4 disabled:opacity-50 ${getThemeClasses("text-success")} ${getThemeClasses("text-success-hover")}`}
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-dashed rounded-lg transition-colors ${getThemeClasses("border-dashed")} ${getThemeClasses("border-dashed-hover")}`}>
|
||||
<div className="space-y-1 text-center">
|
||||
<DocumentArrowUpIcon className={`mx-auto h-12 w-12 ${getThemeClasses("text-muted")}`} />
|
||||
<div className={`flex text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`relative cursor-pointer rounded-md font-medium ${getThemeClasses("link-primary")} focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 ${getThemeClasses("input-focus-ring")}`}
|
||||
>
|
||||
<span>Upload a file</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
name="file"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
disabled={isFetching}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<p className="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p className={`text-xs ${getThemeClasses("text-muted")}`}>
|
||||
Any file type up to {maxFileSize / (1024 * 1024)}
|
||||
MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.file && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{errors.file}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{isFetching && uploadProgress > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
Uploading...
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={`w-full rounded-full h-2 overflow-hidden ${getThemeClasses("progress-bg")}`}>
|
||||
<div
|
||||
className={`${getThemeClasses("progress-bar")} h-full transition-all duration-300`}
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={`flex justify-between items-center pt-6 border-t ${getThemeClasses("border-secondary")}`}
|
||||
>
|
||||
{backPath && (
|
||||
<BackButton to={backPath} label={backLabel} size="lg" />
|
||||
)}
|
||||
<CreateButton
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!title || !selectedFile || isFetching || !uploadAllowed
|
||||
}
|
||||
size="lg"
|
||||
icon={CheckCircleIcon}
|
||||
>
|
||||
Save
|
||||
</CreateButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Custom comparison for performance optimization
|
||||
(prevProps, nextProps) => {
|
||||
// Use reference equality for config object
|
||||
// Parent should memoize the config to prevent unnecessary re-renders
|
||||
return prevProps.config === nextProps.config;
|
||||
}
|
||||
);
|
||||
|
||||
EntityAttachmentAddPageContent.displayName = "EntityAttachmentAddPageContent";
|
||||
|
||||
function EntityAttachmentAddPage(props) {
|
||||
return <EntityAttachmentAddPageContent {...props} />;
|
||||
}
|
||||
|
||||
EntityAttachmentAddPage.displayName = "EntityAttachmentAddPage";
|
||||
|
||||
export default EntityAttachmentAddPage;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EntityAttachmentAddPage';
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
// File: src/components/UIX/EntityAttachmentDetailPage/EntityAttachmentDetailPage.jsx
|
||||
|
||||
import React, { useState, useCallback, useMemo, memo } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
import { UIXThemeProvider } from "../";
|
||||
// NOTE: Commented out - business logic component not available in this project
|
||||
// import AttachmentDetailView from "../../business/views/AttachmentDetailView";
|
||||
|
||||
/**
|
||||
* EntityAttachmentDetailPage - A reusable page component for entity attachment detail management
|
||||
*
|
||||
* This component provides a complete attachment detail page with consistent layout,
|
||||
* data fetching, and error handling. It's designed to work with any entity type
|
||||
* (staff, customers, events, etc.) by accepting configuration props.
|
||||
*/
|
||||
const EntityAttachmentDetailPage = memo(({ config }) => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
// Extract IDs from URL parameters - memoized to prevent repeated access
|
||||
const entityId = useMemo(
|
||||
() => params[config.entityIdParam || "id"],
|
||||
[params, config.entityIdParam],
|
||||
);
|
||||
|
||||
const attachmentId = useMemo(
|
||||
() => params[config.attachmentIdParam || "aid"],
|
||||
[params, config.attachmentIdParam],
|
||||
);
|
||||
|
||||
// Component states
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
const [alertStatus, setAlertStatus] = useState("");
|
||||
|
||||
// Unauthorized callback - memoized to prevent recreation
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Clear alert callback - memoized once
|
||||
const onAlertClear = useCallback(() => {
|
||||
setAlertMessage("");
|
||||
setAlertStatus("");
|
||||
}, []);
|
||||
|
||||
// Fetch attachment details wrapper - properly memoized
|
||||
const onAttachmentFetch = useCallback(
|
||||
async (attachmentIdParam, onUnauthorizedCallback) => {
|
||||
try {
|
||||
return await config.onAttachmentFetch(
|
||||
attachmentIdParam,
|
||||
onUnauthorizedCallback || onUnauthorized,
|
||||
);
|
||||
} catch (error) {
|
||||
setAlertMessage(error.message || "Failed to load attachment details");
|
||||
setAlertStatus("error");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[config.onAttachmentFetch, onUnauthorized],
|
||||
);
|
||||
|
||||
// Build breadcrumbs - memoized to prevent recreation
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!config.routes.buildBreadcrumbs) {
|
||||
return [];
|
||||
}
|
||||
return config.routes.buildBreadcrumbs(entityId, attachmentId);
|
||||
}, [config.routes.buildBreadcrumbs, entityId, attachmentId]);
|
||||
|
||||
// Build paths - memoized to prevent expensive string operations
|
||||
const paths = useMemo(() => {
|
||||
const backPath = config.routes.backPath
|
||||
? config.routes.backPath.replace("{entityId}", entityId)
|
||||
: `/admin/${config.entityType}s`;
|
||||
|
||||
const editPath = config.routes.editPath
|
||||
? config.routes.editPath
|
||||
.replace("{entityId}", entityId)
|
||||
.replace("{attachmentId}", attachmentId)
|
||||
: null;
|
||||
|
||||
const deletePath = config.routes.deletePath
|
||||
? config.routes.deletePath
|
||||
.replace("{entityId}", entityId)
|
||||
.replace("{attachmentId}", attachmentId)
|
||||
: null;
|
||||
|
||||
return { backPath, editPath, deletePath };
|
||||
}, [
|
||||
config.routes.backPath,
|
||||
config.routes.editPath,
|
||||
config.routes.deletePath,
|
||||
config.entityType,
|
||||
entityId,
|
||||
attachmentId,
|
||||
]);
|
||||
|
||||
// Memoize header configuration
|
||||
const headerConfig = useMemo(
|
||||
() => ({
|
||||
itemType: config.header.itemType || config.entityType,
|
||||
itemIcon: config.header.itemIcon || DocumentTextIcon,
|
||||
backLabel: config.header.backLabel || "Back to Attachments",
|
||||
pageTitle:
|
||||
config.header.pageTitle || `${config.entityType} - Attachment Detail`,
|
||||
pageIcon: config.header.pageIcon || DocumentTextIcon,
|
||||
}),
|
||||
[config.header, config.entityType],
|
||||
);
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
{/* NOTE: AttachmentDetailView component not available - placeholder */}
|
||||
<div className="p-8">
|
||||
<p>EntityAttachmentDetailPage: Business logic component not available in this project.</p>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display name for better debugging
|
||||
EntityAttachmentDetailPage.displayName = "EntityAttachmentDetailPage";
|
||||
|
||||
export default EntityAttachmentDetailPage;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EntityAttachmentDetailPage.jsx';
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
// File: src/components/UIX/EntityAttachmentListPage/EntityAttachmentListPage.jsx
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { PaperClipIcon } from "@heroicons/react/24/outline";
|
||||
import { AttachmentsView } from "../";
|
||||
|
||||
// Development-only logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
const log = (...args) => DEBUG && console.log(...args);
|
||||
const error = (...args) => console.error(...args); // Keep errors in production
|
||||
|
||||
/**
|
||||
* EntityAttachmentListPage - A reusable page component for entity attachment management
|
||||
*
|
||||
* This component provides a complete attachment list page with consistent layout,
|
||||
* data fetching, pagination, and error handling.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - React.memo for component memoization
|
||||
* - useCallback for all event handlers
|
||||
* - useMemo for all derived data
|
||||
* - AbortController for request cancellation
|
||||
* - Refs for lifecycle management
|
||||
* - Conditional development logging
|
||||
* - Stable dependency arrays
|
||||
*/
|
||||
const EntityAttachmentListPage = memo(({ config, className = "" }) => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
// Memoize entity ID extraction
|
||||
const entityId = useMemo(
|
||||
() => params[config.entityIdParam || "id"],
|
||||
[params, config.entityIdParam],
|
||||
);
|
||||
|
||||
// Use refs to track component lifecycle
|
||||
const isMounted = useRef(true);
|
||||
const isFetchingRef = useRef(false); // Track fetch state synchronously
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Reset isMounted on every render (handles React Strict Mode remounts)
|
||||
isMounted.current = true;
|
||||
|
||||
// Component states
|
||||
const [isFetching, setFetching] = useState(false);
|
||||
const [entity, setEntity] = useState({});
|
||||
const [attachments, setAttachments] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// Pagination state
|
||||
const [pageSize, setPageSize] = useState(config.defaultPageSize || 50);
|
||||
const [previousCursors, setPreviousCursors] = useState([]);
|
||||
const [currentCursor, setCurrentCursor] = useState("");
|
||||
const [nextCursor, setNextCursor] = useState("");
|
||||
|
||||
// Track if we need to refetch
|
||||
const [shouldRefetch, setShouldRefetch] = useState(false);
|
||||
|
||||
// Memoize callbacks
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
const onErrorClear = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
// Fetch entity data - stable reference without state dependencies
|
||||
const fetchEntityData = useCallback(async () => {
|
||||
if (!config.fetchEntity || !entityId) return {};
|
||||
|
||||
try {
|
||||
log("[EntityAttachmentListPage] Fetching entity data for ID:", entityId);
|
||||
const entityData = await config.fetchEntity(entityId, onUnauthorized);
|
||||
log("[EntityAttachmentListPage] Entity data received:", entityData);
|
||||
if (isMounted.current) {
|
||||
setEntity(entityData);
|
||||
}
|
||||
return entityData;
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentListPage] Error fetching entity:", err);
|
||||
if (isMounted.current) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
general: err.message || "Failed to load entity",
|
||||
}));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, [entityId, config, onUnauthorized]);
|
||||
|
||||
// Fetch attachments data - stable reference without state dependencies
|
||||
const fetchAttachmentsData = useCallback(
|
||||
async (cursor, size) => {
|
||||
if (!config.fetchAttachments || !entityId) return;
|
||||
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
// FIX: Use camelCase for API parameters
|
||||
const params = {
|
||||
...config.attachmentParams,
|
||||
ownershipId: entityId, // ✅ Changed from ownership_id
|
||||
page_size: size,
|
||||
cursor: cursor,
|
||||
};
|
||||
|
||||
log("[EntityAttachmentListPage] Fetching attachments with params:", params);
|
||||
|
||||
const attachmentsData = await config.fetchAttachments(
|
||||
params,
|
||||
onUnauthorized,
|
||||
true, // Force refresh
|
||||
);
|
||||
|
||||
log("[EntityAttachmentListPage] Attachments data received:", attachmentsData);
|
||||
|
||||
if (isMounted.current) {
|
||||
setAttachments(attachmentsData);
|
||||
setNextCursor(
|
||||
attachmentsData?.hasNextPage ? attachmentsData.nextCursor : "",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't treat abort as an error
|
||||
if (err.name === "AbortError") {
|
||||
log("[EntityAttachmentListPage] Request aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
error("[EntityAttachmentListPage] Error fetching attachments:", err);
|
||||
if (isMounted.current) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
attachments: err.message || "Failed to load attachments",
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[entityId, config, onUnauthorized],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initial load effect - only runs once per entityId
|
||||
useEffect(() => {
|
||||
// Skip if no entityId
|
||||
if (!entityId) {
|
||||
log("[EntityAttachmentListPage] No entityId, skipping load");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if currently fetching (prevents double-fetch in React Strict Mode)
|
||||
if (isFetchingRef.current) {
|
||||
log("[EntityAttachmentListPage] Already fetching (ref check), skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
// Set ref immediately to prevent double-fetch
|
||||
isFetchingRef.current = true;
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
|
||||
// Fetch entity data first
|
||||
await fetchEntityData();
|
||||
|
||||
// Then fetch attachments with initial settings
|
||||
await fetchAttachmentsData("", config.defaultPageSize || 50);
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentListPage] Error loading initial data:", err);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
isFetchingRef.current = false;
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entityId]); // Only re-run when entityId changes
|
||||
|
||||
// Separate effect for pagination changes
|
||||
useEffect(() => {
|
||||
// Skip if not triggered
|
||||
if (!shouldRefetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPageData = async () => {
|
||||
try {
|
||||
setFetching(true);
|
||||
await fetchAttachmentsData(currentCursor, pageSize);
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentListPage] Error loading page data:", err);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setFetching(false);
|
||||
setShouldRefetch(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPageData();
|
||||
}, [currentCursor, pageSize, shouldRefetch, fetchAttachmentsData]);
|
||||
|
||||
// Pagination handlers - memoized
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (nextCursor) {
|
||||
setPreviousCursors((prev) => [...prev, currentCursor]);
|
||||
setCurrentCursor(nextCursor);
|
||||
setShouldRefetch(true);
|
||||
}
|
||||
}, [currentCursor, nextCursor]);
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
if (previousCursors.length > 0) {
|
||||
setPreviousCursors((prev) => {
|
||||
const arr = [...prev];
|
||||
const previousCursor = arr.pop();
|
||||
setCurrentCursor(previousCursor || "");
|
||||
setShouldRefetch(true);
|
||||
return arr;
|
||||
});
|
||||
}
|
||||
}, [previousCursors]);
|
||||
|
||||
const handlePageSizeChange = useCallback((newPageSize) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentCursor("");
|
||||
setPreviousCursors([]);
|
||||
setNextCursor("");
|
||||
setShouldRefetch(true);
|
||||
}, []);
|
||||
|
||||
// Handle refresh - properly reset and refetch
|
||||
const handleRefresh = useCallback(async () => {
|
||||
try {
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
|
||||
// Fetch entity data
|
||||
await fetchEntityData();
|
||||
|
||||
// Reset pagination and refetch attachments
|
||||
setCurrentCursor("");
|
||||
setPreviousCursors([]);
|
||||
await fetchAttachmentsData("", pageSize);
|
||||
} catch (err) {
|
||||
error("[EntityAttachmentListPage] Error refreshing:", err);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}, [fetchEntityData, fetchAttachmentsData, pageSize]);
|
||||
|
||||
// Handle attachment click - memoized
|
||||
const onAttachmentClick = useCallback(
|
||||
(attachment) => {
|
||||
if (config.onAttachmentClick) {
|
||||
config.onAttachmentClick(attachment, entityId, navigate);
|
||||
}
|
||||
},
|
||||
[config, entityId, navigate],
|
||||
);
|
||||
|
||||
// Handle attachment selection for deletion - memoized
|
||||
const onSelectForDeletion = useCallback(
|
||||
(attachment) => {
|
||||
if (config.onDeleteAttachment) {
|
||||
config.onDeleteAttachment(attachment, entityId, navigate);
|
||||
}
|
||||
},
|
||||
[config, entityId, navigate],
|
||||
);
|
||||
|
||||
// Handle entity refresh - memoized
|
||||
const handleRefreshEntity = useCallback(
|
||||
async (entityIdParam, onUnauthorizedParam) => {
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const entityData = await config.fetchEntity(
|
||||
entityIdParam || entityId,
|
||||
onUnauthorizedParam || onUnauthorized,
|
||||
);
|
||||
if (isMounted.current) {
|
||||
setEntity(entityData);
|
||||
}
|
||||
|
||||
// Also refresh attachments
|
||||
await fetchAttachmentsData(currentCursor, pageSize);
|
||||
|
||||
return entityData;
|
||||
} catch (err) {
|
||||
if (isMounted.current) {
|
||||
setErrors({
|
||||
general: err.message || "Failed to load entity details. Please try again.",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, entityId, onUnauthorized, fetchAttachmentsData, currentCursor, pageSize],
|
||||
);
|
||||
|
||||
// Memoize configuration objects - use stable config dependency
|
||||
const breadcrumbs = useMemo(
|
||||
() =>
|
||||
config.routes?.buildBreadcrumbs
|
||||
? config.routes.buildBreadcrumbs(entity, entityId)
|
||||
: [],
|
||||
[config, entity, entityId],
|
||||
);
|
||||
|
||||
const tabItems = useMemo(
|
||||
() =>
|
||||
config.routes?.buildTabs ? config.routes.buildTabs(entity, entityId) : [],
|
||||
[config, entity, entityId],
|
||||
);
|
||||
|
||||
const fieldSections = useMemo(
|
||||
() => (config.buildFieldSections ? config.buildFieldSections(entity) : []),
|
||||
[config, entity],
|
||||
);
|
||||
|
||||
const alerts = useMemo(
|
||||
() => (config.buildAlerts ? config.buildAlerts(entity) : {}),
|
||||
[config, entity],
|
||||
);
|
||||
|
||||
const canAdd = useMemo(
|
||||
() => (config.canAddAttachments ? config.canAddAttachments(entity) : true),
|
||||
[config, entity],
|
||||
);
|
||||
|
||||
// Separate actionButtons to avoid isFetching dependency causing full recreation
|
||||
const baseActionButtons = useMemo(() => {
|
||||
return config.routes?.buildActionButtons
|
||||
? config.routes.buildActionButtons(entity, entityId, navigate)
|
||||
: [];
|
||||
}, [config, entity, entityId, navigate]);
|
||||
|
||||
const actionButtons = useMemo(() => {
|
||||
const buttons = [...baseActionButtons];
|
||||
|
||||
// Add refresh button if not already present
|
||||
const hasRefresh = buttons.some((btn) => btn.label === "Refresh");
|
||||
if (!hasRefresh) {
|
||||
buttons.push({
|
||||
variant: "outline",
|
||||
onClick: handleRefresh,
|
||||
icon: null,
|
||||
label: "Refresh",
|
||||
disabled: isFetching,
|
||||
});
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}, [baseActionButtons, handleRefresh, isFetching]);
|
||||
|
||||
// Memoize route paths
|
||||
const paths = useMemo(() => {
|
||||
const addPath = config.routes?.addPath
|
||||
? config.routes.addPath.replace("{entityId}", entityId)
|
||||
: "";
|
||||
const viewPath = config.routes?.viewPath
|
||||
? config.routes.viewPath.replace("{entityId}", entityId)
|
||||
: "";
|
||||
const editPath = config.routes?.editPath
|
||||
? config.routes.editPath.replace("{entityId}", entityId)
|
||||
: "";
|
||||
const deletePath = config.routes?.deletePath
|
||||
? config.routes.deletePath.replace("{entityId}", entityId)
|
||||
: "";
|
||||
|
||||
return { addPath, viewPath, editPath, deletePath };
|
||||
}, [config.routes, entityId]);
|
||||
|
||||
// Memoize loading state - only show loading on initial load
|
||||
const isLoading = useMemo(
|
||||
() => isFetching && !entity.id && !attachments,
|
||||
[isFetching, entity.id, attachments],
|
||||
);
|
||||
|
||||
const errorMessage = useMemo(() => errors.general, [errors.general]);
|
||||
|
||||
return (
|
||||
<AttachmentsView
|
||||
entityData={entity}
|
||||
entityId={entityId}
|
||||
entityType={config.entityType}
|
||||
breadcrumbItems={breadcrumbs}
|
||||
headerConfig={config.header}
|
||||
fieldSections={fieldSections}
|
||||
actionButtons={actionButtons}
|
||||
tabs={tabItems}
|
||||
alerts={alerts}
|
||||
attachments={attachments}
|
||||
onAttachmentClick={onAttachmentClick}
|
||||
onDeleteAttachment={onSelectForDeletion}
|
||||
onRefreshEntity={handleRefreshEntity}
|
||||
onUnauthorized={onUnauthorized}
|
||||
isLoading={isLoading}
|
||||
error={errorMessage}
|
||||
onErrorClose={onErrorClear}
|
||||
canAdd={canAdd}
|
||||
addPath={paths.addPath}
|
||||
viewPath={paths.viewPath}
|
||||
editPath={paths.editPath}
|
||||
deletePath={paths.deletePath}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
previousCursors={previousCursors}
|
||||
nextCursor={nextCursor}
|
||||
onNextClick={handleNextPage}
|
||||
onPreviousClick={handlePreviousPage}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display name for better debugging
|
||||
EntityAttachmentListPage.displayName = "EntityAttachmentListPage";
|
||||
|
||||
export default EntityAttachmentListPage;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EntityAttachmentListPage.jsx';
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// File: src/components/UIX/EntityAttachmentUpdatePage/EntityAttachmentUpdatePage.jsx
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { UIXThemeProvider } from "../";
|
||||
// NOTE: Commented out - business logic component not available in this project
|
||||
// import AttachmentUpdateView from "../../business/views/AttachmentUpdateView";
|
||||
|
||||
/**
|
||||
* EntityAttachmentUpdatePage - A reusable page component for entity attachment update management
|
||||
*
|
||||
* This component provides a complete attachment update page with consistent layout,
|
||||
* data fetching, and error handling.
|
||||
*/
|
||||
const EntityAttachmentUpdatePage = memo(({ config }) => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const timeoutRef = useRef(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
// Reset isMounted on every render (handles React Strict Mode remounts)
|
||||
isMounted.current = true;
|
||||
|
||||
// Extract IDs from URL parameters - memoized
|
||||
const entityId = useMemo(
|
||||
() => params[config.entityIdParam || "id"],
|
||||
[params, config.entityIdParam],
|
||||
);
|
||||
|
||||
const attachmentId = useMemo(
|
||||
() => params[config.attachmentIdParam || "aid"],
|
||||
[params, config.attachmentIdParam],
|
||||
);
|
||||
|
||||
// Component states
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
const [alertStatus, setAlertStatus] = useState("");
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoized callbacks
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
const onAlertClear = useCallback(() => {
|
||||
setAlertMessage("");
|
||||
setAlertStatus("");
|
||||
}, []);
|
||||
|
||||
// Fetch attachment details wrapper - memoized
|
||||
const onAttachmentFetch = useCallback(
|
||||
async (attachmentIdParam) => {
|
||||
try {
|
||||
return await config.onAttachmentFetch(
|
||||
attachmentIdParam,
|
||||
onUnauthorized,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMounted.current) {
|
||||
setAlertMessage(error.message || "Failed to load attachment details");
|
||||
setAlertStatus("error");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[config.onAttachmentFetch, onUnauthorized],
|
||||
);
|
||||
|
||||
// Build redirect path - memoized helper
|
||||
const buildRedirectPath = useCallback(
|
||||
(path) => {
|
||||
if (!path) return null;
|
||||
return path
|
||||
.replace("{entityId}", entityId)
|
||||
.replace("{attachmentId}", attachmentId);
|
||||
},
|
||||
[entityId, attachmentId],
|
||||
);
|
||||
|
||||
// Update attachment wrapper - memoized
|
||||
const onAttachmentUpdate = useCallback(
|
||||
async (attachmentIdParam, updateData, onUnauthorizedCallback) => {
|
||||
try {
|
||||
const response = await config.onAttachmentUpdate(
|
||||
attachmentIdParam,
|
||||
updateData,
|
||||
onUnauthorizedCallback || onUnauthorized,
|
||||
);
|
||||
|
||||
// Always update state - React will handle updates gracefully
|
||||
setAlertMessage("Attachment updated successfully!");
|
||||
setAlertStatus("success");
|
||||
|
||||
// Navigate to success redirect path after successful update
|
||||
const redirectPath = buildRedirectPath(
|
||||
config.routes.successRedirectPath || config.routes.backPath,
|
||||
);
|
||||
|
||||
if (redirectPath) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
navigate(redirectPath);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Always update state - React will handle updates gracefully
|
||||
setAlertMessage(error.message || "Failed to update attachment");
|
||||
setAlertStatus("error");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
config.onAttachmentUpdate,
|
||||
config.routes.successRedirectPath,
|
||||
config.routes.backPath,
|
||||
buildRedirectPath,
|
||||
navigate,
|
||||
onUnauthorized,
|
||||
],
|
||||
);
|
||||
|
||||
// Build breadcrumbs - properly memoized
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!config.routes.buildBreadcrumbs) {
|
||||
return [];
|
||||
}
|
||||
return config.routes.buildBreadcrumbs(entityId, attachmentId);
|
||||
}, [config.routes.buildBreadcrumbs, entityId, attachmentId]);
|
||||
|
||||
// Build paths - memoized
|
||||
const paths = useMemo(() => {
|
||||
const backPath = config.routes.backPath
|
||||
? buildRedirectPath(config.routes.backPath)
|
||||
: `/admin/${config.entityType}s`;
|
||||
|
||||
return { backPath };
|
||||
}, [config.routes.backPath, config.entityType, buildRedirectPath]);
|
||||
|
||||
// Memoize header configuration
|
||||
const headerConfig = useMemo(
|
||||
() => ({
|
||||
entityType: config.header.entityType || config.entityType,
|
||||
entityIcon: config.header.entityIcon || PencilSquareIcon,
|
||||
basePath: config.header.basePath || `/admin/${config.entityType}s`,
|
||||
backLabel: config.header.backLabel || "Back to Detail",
|
||||
pageTitle:
|
||||
config.header.pageTitle || `${config.entityType} - Update Attachment`,
|
||||
pageIcon: config.header.pageIcon || PencilSquareIcon,
|
||||
}),
|
||||
[config.header, config.entityType],
|
||||
);
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
{/* NOTE: AttachmentUpdateView component not available - placeholder */}
|
||||
<div className="p-8">
|
||||
<p>EntityAttachmentUpdatePage: Business logic component not available in this project.</p>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display name for better debugging
|
||||
EntityAttachmentUpdatePage.displayName = "EntityAttachmentUpdatePage";
|
||||
|
||||
export default EntityAttachmentUpdatePage;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EntityAttachmentUpdatePage.jsx';
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
// File: src/components/UIX/EntityCommentsPage/EntityCommentsPage.jsx
|
||||
// Reusable entity comments list page component
|
||||
//
|
||||
// This component provides a complete page layout for displaying and managing
|
||||
// entity comments across different entity types (staff, customer, organization, etc.)
|
||||
//
|
||||
// Usage Example:
|
||||
// <EntityCommentsPage
|
||||
// config={{
|
||||
// entityId: "123",
|
||||
// entityType: "staff member",
|
||||
// fetchEntity: async (id, onUnauthorized) => {...},
|
||||
// createComment: async (id, content, onUnauthorized) => {...},
|
||||
// breadcrumbs: { ... },
|
||||
// header: { ... },
|
||||
// tabs: { ... },
|
||||
// entityDisplay: { ... },
|
||||
// }}
|
||||
// />
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CommentsView } from "../";
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development";
|
||||
const log = (...args) => DEBUG && console.log(...args);
|
||||
const error = (...args) => console.error(...args);
|
||||
|
||||
/**
|
||||
* EntityCommentsPage Component
|
||||
*
|
||||
* A reusable whole-page component for entity comments management.
|
||||
* Wraps CommentsView with data fetching, state management, and error handling.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.config - Configuration object containing all settings
|
||||
*
|
||||
* Config Structure:
|
||||
* {
|
||||
* // Core settings
|
||||
* entityId: string, // Entity ID
|
||||
* entityType: string, // Entity type for display (e.g., "staff member")
|
||||
*
|
||||
* // Data fetching functions
|
||||
* fetchEntity: async (entityId, onUnauthorized) => entity,
|
||||
* createComment: async (entityId, content, onUnauthorized) => updatedEntity,
|
||||
*
|
||||
* // Navigation configuration
|
||||
* breadcrumbs: {
|
||||
* items: [{ label, to, icon, isActive }], // Or function: (entity, entityId) => items
|
||||
* },
|
||||
*
|
||||
* // Header configuration
|
||||
* header: {
|
||||
* title: string,
|
||||
* icon: Component,
|
||||
* loadingText: string,
|
||||
* notFoundTitle: string,
|
||||
* notFoundMessage: string,
|
||||
* notFoundAction: { label, icon, onClick },
|
||||
* },
|
||||
*
|
||||
* // Action buttons configuration
|
||||
* actionButtons: [
|
||||
* { variant, onClick, icon, label, disabled }
|
||||
* ], // Or function: (entity, entityId, navigate, isFetching) => buttons
|
||||
*
|
||||
* // Tabs configuration
|
||||
* tabs: {
|
||||
* items: [{ label, to, icon, isActive }], // Or function: (entity, entityId) => items
|
||||
* },
|
||||
*
|
||||
* // Entity display configuration
|
||||
* entityDisplay: {
|
||||
* buildFieldSections: (entity, themeClasses) => sections,
|
||||
* alerts: { archived: { message, icon }, banned: { message, icon } },
|
||||
* statusConfig: { activeLabel, inactiveLabel, bannedLabel },
|
||||
* typeMap: { 1: "Type 1", 2: "Type 2" },
|
||||
* },
|
||||
* }
|
||||
*/
|
||||
const EntityCommentsPageContent = memo(
|
||||
function EntityCommentsPageContent({ config }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Validate required config
|
||||
if (!config) {
|
||||
error("EntityCommentsPage: config is required");
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
entityId,
|
||||
entityType = "entity",
|
||||
fetchEntity,
|
||||
createComment,
|
||||
breadcrumbs,
|
||||
header,
|
||||
actionButtons: actionButtonsConfig,
|
||||
tabs,
|
||||
entityDisplay,
|
||||
} = config;
|
||||
|
||||
// Component states
|
||||
const [entity, setEntity] = useState({});
|
||||
const [isFetching, setFetching] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// Use refs to track mount status and prevent memory leaks
|
||||
const isMounted = useRef(true);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Handle unauthorized access
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
// Fetch entity data with proper cleanup
|
||||
const fetchEntityData = useCallback(() => {
|
||||
if (!entityId) {
|
||||
log("EntityCommentsPage: No entityId provided, returning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fetchEntity) {
|
||||
error("EntityCommentsPage: fetchEntity function is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
log("EntityCommentsPage: Starting fetch for entityId:", entityId);
|
||||
setFetching(true);
|
||||
setErrors({});
|
||||
|
||||
// Create a promise wrapper to handle the fetch
|
||||
const fetchPromise = new Promise((resolve, reject) => {
|
||||
// Check if component is still mounted before making request
|
||||
if (!isMounted.current) {
|
||||
reject(new Error("Component unmounted"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the fetchEntity function
|
||||
const result = fetchEntity(entityId, onUnauthorized);
|
||||
|
||||
// Handle both callback-based and promise-based fetchers
|
||||
if (result && typeof result.then === "function") {
|
||||
// Promise-based
|
||||
result
|
||||
.then((response) => {
|
||||
if (isMounted.current) {
|
||||
log("EntityCommentsPage: Entity fetched successfully:", response);
|
||||
setEntity(response);
|
||||
setFetching(false);
|
||||
resolve(response);
|
||||
}
|
||||
})
|
||||
.catch((errorResponse) => {
|
||||
if (isMounted.current) {
|
||||
error("EntityCommentsPage: Error fetching entity:", errorResponse);
|
||||
setErrors(errorResponse);
|
||||
setFetching(false);
|
||||
reject(errorResponse);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Callback-based (legacy pattern)
|
||||
// Assume it was already handled in the function
|
||||
setFetching(false);
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle abort signal
|
||||
if (abortControllerRef.current.signal.aborted) {
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerRef.current.signal.addEventListener("abort", () => {
|
||||
log("EntityCommentsPage: Fetch aborted");
|
||||
setFetching(false);
|
||||
});
|
||||
|
||||
return fetchPromise;
|
||||
}, [entityId, fetchEntity, onUnauthorized]);
|
||||
|
||||
// Create comment handler
|
||||
const handleCreateComment = useCallback(
|
||||
async (id, content, onUnauthorizedCallback) => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (!createComment) {
|
||||
error("EntityCommentsPage: createComment function is required");
|
||||
throw new Error("Comment creation not configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createComment(
|
||||
id,
|
||||
content,
|
||||
onUnauthorizedCallback || onUnauthorized,
|
||||
);
|
||||
|
||||
// Update local entity state with the result
|
||||
if (result && isMounted.current) {
|
||||
setEntity(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error("EntityCommentsPage: Error creating comment:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[createComment, onUnauthorized],
|
||||
);
|
||||
|
||||
// Initial load and cleanup
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
fetchEntityData();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
|
||||
// Cancel any ongoing requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any pending states
|
||||
setEntity({});
|
||||
setFetching(false);
|
||||
setErrors({});
|
||||
};
|
||||
}, [entityId]); // Only depend on entityId, not fetchEntityData
|
||||
|
||||
// Build breadcrumb items
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
if (!breadcrumbs) return [];
|
||||
|
||||
if (typeof breadcrumbs.items === "function") {
|
||||
return breadcrumbs.items(entity, entityId);
|
||||
}
|
||||
|
||||
return breadcrumbs.items || [];
|
||||
}, [breadcrumbs, entity, entityId]);
|
||||
|
||||
// Build action buttons
|
||||
const actionButtons = useMemo(() => {
|
||||
if (!actionButtonsConfig) return [];
|
||||
|
||||
if (typeof actionButtonsConfig === "function") {
|
||||
return actionButtonsConfig(entity, entityId, navigate, isFetching);
|
||||
}
|
||||
|
||||
// If it's an array, map through and add refresh functionality
|
||||
const buttons = Array.isArray(actionButtonsConfig) ? [...actionButtonsConfig] : [];
|
||||
|
||||
// Add refresh button if not present and fetchEntityData exists
|
||||
const hasRefresh = buttons.some((btn) => btn.label === "Refresh" || btn.label === "Refreshing...");
|
||||
if (!hasRefresh && fetchEntityData) {
|
||||
buttons.push({
|
||||
variant: "outline",
|
||||
onClick: () => fetchEntityData(),
|
||||
label: isFetching ? "Refreshing..." : "Refresh",
|
||||
disabled: isFetching,
|
||||
});
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}, [actionButtonsConfig, entity, entityId, navigate, isFetching, fetchEntityData]);
|
||||
|
||||
// Build tabs
|
||||
const tabItems = useMemo(() => {
|
||||
if (!tabs) return [];
|
||||
|
||||
if (typeof tabs.items === "function") {
|
||||
return tabs.items(entity, entityId);
|
||||
}
|
||||
|
||||
return tabs.items || [];
|
||||
}, [tabs, entity, entityId]);
|
||||
|
||||
// Build field sections
|
||||
const fieldSections = useMemo(() => {
|
||||
if (!entityDisplay?.buildFieldSections) return [];
|
||||
if (!entity || !entity.id) return [];
|
||||
|
||||
return entityDisplay.buildFieldSections(entity);
|
||||
}, [entityDisplay, entity]);
|
||||
|
||||
// Pass through entity display configuration
|
||||
const alerts = entityDisplay?.alerts || {};
|
||||
const statusConfig = entityDisplay?.statusConfig || {};
|
||||
const typeMap = entityDisplay?.typeMap || {};
|
||||
|
||||
return (
|
||||
<CommentsView
|
||||
entityData={entity}
|
||||
entityId={entityId}
|
||||
entityType={entityType}
|
||||
breadcrumbItems={breadcrumbItems}
|
||||
headerConfig={header}
|
||||
fieldSections={fieldSections}
|
||||
actionButtons={actionButtons}
|
||||
tabs={tabItems}
|
||||
alerts={alerts}
|
||||
onCreateComment={handleCreateComment}
|
||||
onRefreshEntity={fetchEntityData}
|
||||
onUnauthorized={onUnauthorized}
|
||||
isLoading={isFetching && !entity.id}
|
||||
error={errors}
|
||||
onErrorClose={() => setErrors({})}
|
||||
statusConfig={statusConfig}
|
||||
typeMap={typeMap}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Custom comparison for performance optimization
|
||||
(prevProps, nextProps) => {
|
||||
// Use reference equality for config object
|
||||
// Parent should memoize the config to prevent unnecessary re-renders
|
||||
return prevProps.config === nextProps.config;
|
||||
}
|
||||
);
|
||||
|
||||
EntityCommentsPageContent.displayName = "EntityCommentsPageContent";
|
||||
|
||||
function EntityCommentsPage(props) {
|
||||
return <EntityCommentsPageContent {...props} />;
|
||||
}
|
||||
|
||||
EntityCommentsPage.displayName = "EntityCommentsPage";
|
||||
|
||||
export default EntityCommentsPage;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as EntityCommentsPage } from './EntityCommentsPage';
|
||||
export { default } from './EntityCommentsPage';
|
||||
|
|
@ -0,0 +1,919 @@
|
|||
// File: src/components/UIX/EntityFileView/EntityFileView.jsx
|
||||
// EntityFileView Component - Google Drive-like file and folder viewer
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Squares2X2Icon,
|
||||
ListBulletIcon,
|
||||
FolderIcon,
|
||||
DocumentIcon,
|
||||
FolderOpenIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Button,
|
||||
useUIXTheme,
|
||||
SearchFilter,
|
||||
Card,
|
||||
} from "../";
|
||||
|
||||
// Constants
|
||||
const VIEW_TYPE_GRID = "grid";
|
||||
const VIEW_TYPE_LIST = "list";
|
||||
const ITEM_TYPE_FOLDER = "folder";
|
||||
const ITEM_TYPE_FILE = "file";
|
||||
|
||||
/**
|
||||
* EntityFileView - A reusable file and folder viewer component
|
||||
*
|
||||
* Features:
|
||||
* - Grid and List view modes
|
||||
* - Folder navigation with breadcrumbs
|
||||
* - File and folder display
|
||||
* - Search and filtering
|
||||
* - E2EE support via config callbacks
|
||||
* - Google Drive-like interface
|
||||
*
|
||||
* @param {object} config - Configuration object
|
||||
* @param {function} config.fetchData - Fetch function (params, onUnauthorized, forceRefresh) => response
|
||||
* @param {function} config.buildParams - Build fetch parameters
|
||||
* @param {function} config.onItemClick - Handle item click (item, type) => void
|
||||
* @param {function} config.onFolderOpen - Handle folder open (folderId) => void
|
||||
* @param {function} config.processItems - Process/decrypt items (items) => processedItems
|
||||
* @param {function} config.renderFileIcon - Custom file icon renderer (file) => ReactNode
|
||||
* @param {function} config.renderFolderIcon - Custom folder icon renderer (folder) => ReactNode
|
||||
* @param {function} config.renderItemActions - Render item actions (item, type) => ReactNode
|
||||
* @param {array} config.breadcrumbItems - Breadcrumb items for current path
|
||||
* @param {string} config.currentFolderId - Current folder ID (null for root)
|
||||
* @param {string} config.emptyStateTitle - Empty state title
|
||||
* @param {string} config.emptyStateDescription - Empty state description
|
||||
* @param {boolean} config.showCreateButton - Show create folder button
|
||||
* @param {function} config.onCreateFolder - Create folder handler
|
||||
* @param {object} config.filterOptions - Filter configuration (statusOptions, typeOptions, sortOptions, etc.)
|
||||
*/
|
||||
const EntityFileView = memo(({ config }) => {
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Refs for cleanup
|
||||
const isMounted = useRef(true);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
// List state
|
||||
const [items, setItems] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
// Pagination state (cursor-based)
|
||||
const [pageSize, setPageSize] = useState(config.defaultPageSize || 50);
|
||||
const [previousCursors, setPreviousCursors] = useState([]);
|
||||
const [currentCursor, setCurrentCursor] = useState("");
|
||||
const [nextCursor, setNextCursor] = useState("");
|
||||
|
||||
// Filter state
|
||||
const [sortBy, setSortBy] = useState(config.defaultSort || "name");
|
||||
const [sortOrder, setSortOrder] = useState(config.defaultSortOrder || "ASC");
|
||||
const [status, setStatus] = useState(config.defaultStatus || "1");
|
||||
const [type, setType] = useState(config.defaultType || "0");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [tempSearchQuery, setTempSearchQuery] = useState("");
|
||||
const [viewType, setViewType] = useState(
|
||||
config.defaultViewType || VIEW_TYPE_GRID,
|
||||
);
|
||||
|
||||
// Force refresh counter
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
|
||||
// Mount/cleanup effect - Reset isMounted on each mount for StrictMode compatibility
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoized callbacks
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPreviousCursors([]);
|
||||
setCurrentCursor("");
|
||||
setNextCursor("");
|
||||
}, []);
|
||||
|
||||
// Extract config methods and values to prevent infinite loops
|
||||
// These are extracted once and won't change during component lifecycle
|
||||
const currentFolderId = config.currentFolderId;
|
||||
const buildParamsRef = useRef(config.buildParams);
|
||||
const fetchDataRef = useRef(config.fetchData);
|
||||
const processItemsRef = useRef(config.processItems);
|
||||
|
||||
// Update refs when config changes (but don't trigger re-renders)
|
||||
useEffect(() => {
|
||||
buildParamsRef.current = config.buildParams;
|
||||
fetchDataRef.current = config.fetchData;
|
||||
processItemsRef.current = config.processItems;
|
||||
}, [config.buildParams, config.fetchData, config.processItems]);
|
||||
|
||||
// Fetch items - no config object in dependencies to prevent infinite loops
|
||||
const fetchItems = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const params = buildParamsRef.current({
|
||||
pageSize,
|
||||
currentCursor,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
folderId: currentFolderId,
|
||||
});
|
||||
|
||||
const response = await fetchDataRef.current(
|
||||
params,
|
||||
onUnauthorized,
|
||||
forceRefresh,
|
||||
);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// Process items (decrypt if needed)
|
||||
let processedItems = response.results || [];
|
||||
if (processItemsRef.current) {
|
||||
processedItems = await processItemsRef.current(processedItems);
|
||||
}
|
||||
|
||||
setItems(processedItems);
|
||||
setNextCursor(
|
||||
response.hasNextPage || response.nextCursor
|
||||
? response.nextCursor || ""
|
||||
: "",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setErrors({
|
||||
general: "Failed to load items. Please try again.",
|
||||
});
|
||||
|
||||
// Only log errors in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("EntityFileView: Failed to fetch items");
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentCursor,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
currentFolderId,
|
||||
onUnauthorized,
|
||||
],
|
||||
);
|
||||
|
||||
// Pagination handlers
|
||||
const handlePageChange = useCallback(
|
||||
(direction) => {
|
||||
if (direction === "next" && nextCursor) {
|
||||
setPreviousCursors((prev) => [...prev, currentCursor]);
|
||||
setCurrentCursor(nextCursor);
|
||||
} else if (direction === "previous" && previousCursors.length > 0) {
|
||||
setPreviousCursors((prev) => {
|
||||
const newPrev = [...prev];
|
||||
const previousCursor = newPrev.pop();
|
||||
setCurrentCursor(previousCursor);
|
||||
return newPrev;
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentCursor, nextCursor, previousCursors],
|
||||
);
|
||||
|
||||
// Search handler
|
||||
const handleSearch = useCallback(() => {
|
||||
setSearchQuery(tempSearchQuery);
|
||||
resetPagination();
|
||||
}, [tempSearchQuery, resetPagination]);
|
||||
|
||||
// Filter handlers
|
||||
const handleStatusChange = useCallback(
|
||||
(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(newType) => {
|
||||
setType(newType);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sortValue) => {
|
||||
const [field, order] = sortValue.split(",");
|
||||
setSortBy(field);
|
||||
setSortOrder(order);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handlePageSizeChange = useCallback(
|
||||
(newPageSize) => {
|
||||
setPageSize(parseInt(newPageSize));
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setStatus(config.defaultStatus || "1");
|
||||
setType(config.defaultType || "0");
|
||||
setSortBy(config.defaultSort || "name");
|
||||
setSortOrder(config.defaultSortOrder || "ASC");
|
||||
setSearchQuery("");
|
||||
setTempSearchQuery("");
|
||||
resetPagination();
|
||||
setRefreshCounter((prev) => prev + 1);
|
||||
}, [config, resetPagination]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchItems(true);
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSuccessMessageClose = useCallback(() => {
|
||||
setSuccessMessage("");
|
||||
}, []);
|
||||
|
||||
// View type handlers
|
||||
const handleViewTypeChange = useCallback((newViewType) => {
|
||||
setViewType(newViewType);
|
||||
}, []);
|
||||
|
||||
// Item interaction handlers
|
||||
const handleItemClick = useCallback(
|
||||
(item, itemType) => {
|
||||
if (config.onItemClick) {
|
||||
config.onItemClick(item, itemType);
|
||||
}
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleFolderOpen = useCallback(
|
||||
(folder) => {
|
||||
if (config.onFolderOpen) {
|
||||
config.onFolderOpen(folder);
|
||||
}
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
// Determine item type
|
||||
const getItemType = useCallback((item) => {
|
||||
// Check if item has a type field
|
||||
if (item.type === ITEM_TYPE_FOLDER || item.type === "collection") {
|
||||
return ITEM_TYPE_FOLDER;
|
||||
}
|
||||
if (item.type === ITEM_TYPE_FILE) {
|
||||
return ITEM_TYPE_FILE;
|
||||
}
|
||||
|
||||
// Check for folder-specific fields
|
||||
if (item.isFolder || item.collection_id || item.collectionId) {
|
||||
return ITEM_TYPE_FOLDER;
|
||||
}
|
||||
|
||||
// Default to file
|
||||
return ITEM_TYPE_FILE;
|
||||
}, []);
|
||||
|
||||
// Get file icon
|
||||
const getFileIcon = useCallback(
|
||||
(file) => {
|
||||
if (config.renderFileIcon) {
|
||||
return config.renderFileIcon(file);
|
||||
}
|
||||
|
||||
// Default file icon based on extension
|
||||
const fileName = file.name || file.encrypted_name || "";
|
||||
const extension = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const iconClass = "h-8 w-8";
|
||||
|
||||
if (["pdf"].includes(extension)) {
|
||||
return <DocumentIcon className={`${iconClass} ${getThemeClasses("file-icon-pdf")}`} />;
|
||||
}
|
||||
if (["doc", "docx", "txt"].includes(extension)) {
|
||||
return <DocumentIcon className={`${iconClass} ${getThemeClasses("file-icon-doc")}`} />;
|
||||
}
|
||||
if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
|
||||
return <DocumentIcon className={`${iconClass} ${getThemeClasses("file-icon-design")}`} />;
|
||||
}
|
||||
|
||||
return <DocumentIcon className={`${iconClass} ${getThemeClasses("file-icon-default")}`} />;
|
||||
},
|
||||
[config, getThemeClasses],
|
||||
);
|
||||
|
||||
// Get folder icon
|
||||
const getFolderIcon = useCallback(
|
||||
(folder) => {
|
||||
if (config.renderFolderIcon) {
|
||||
return config.renderFolderIcon(folder);
|
||||
}
|
||||
|
||||
return <FolderIcon className={`h-8 w-8 ${getThemeClasses("icon-info")}`} />;
|
||||
},
|
||||
[config, getThemeClasses],
|
||||
);
|
||||
|
||||
// Separate items into folders and files
|
||||
const { folders, files } = useMemo(() => {
|
||||
const foldersList = [];
|
||||
const filesList = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const itemType = getItemType(item);
|
||||
if (itemType === ITEM_TYPE_FOLDER) {
|
||||
foldersList.push(item);
|
||||
} else {
|
||||
filesList.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { folders: foldersList, files: filesList };
|
||||
}, [items, getItemType]);
|
||||
|
||||
// Memoized configurations
|
||||
const searchFilterConfig = useMemo(
|
||||
() => ({
|
||||
searchTerm: searchQuery,
|
||||
tempSearchTerm: tempSearchQuery,
|
||||
onSearchTermChange: setTempSearchQuery,
|
||||
onSearch: handleSearch,
|
||||
searchPlaceholder: config.searchPlaceholder || "Search files and folders...",
|
||||
statusOptions: config.filterOptions?.statusOptions || [],
|
||||
statusFilter: status,
|
||||
onStatusFilterChange: handleStatusChange,
|
||||
typeOptions: config.filterOptions?.typeOptions || [],
|
||||
typeFilter: type,
|
||||
onTypeFilterChange: handleTypeChange,
|
||||
sortOptions: config.filterOptions?.sortOptions || [],
|
||||
sortValue: `${sortBy},${sortOrder}`,
|
||||
onSortChange: handleSortChange,
|
||||
pageSizeOptions: config.filterOptions?.pageSizeOptions || [],
|
||||
pageSize,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onClearFilters: handleClearFilters,
|
||||
onRefresh: handleRefresh,
|
||||
}),
|
||||
[
|
||||
searchQuery,
|
||||
tempSearchQuery,
|
||||
handleSearch,
|
||||
config,
|
||||
status,
|
||||
handleStatusChange,
|
||||
type,
|
||||
handleTypeChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
pageSize,
|
||||
handlePageSizeChange,
|
||||
handleClearFilters,
|
||||
handleRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoized header actions
|
||||
const headerActions = useMemo(
|
||||
() => [
|
||||
<div key="view-toggle" className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={viewType === VIEW_TYPE_LIST ? "primary" : "ghost"}
|
||||
onClick={() => handleViewTypeChange(VIEW_TYPE_LIST)}
|
||||
size="sm"
|
||||
title="List View"
|
||||
>
|
||||
<ListBulletIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewType === VIEW_TYPE_GRID ? "primary" : "ghost"}
|
||||
onClick={() => handleViewTypeChange(VIEW_TYPE_GRID)}
|
||||
size="sm"
|
||||
title="Grid View"
|
||||
>
|
||||
<Squares2X2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>,
|
||||
...(config.showCreateButton
|
||||
? [
|
||||
<Button
|
||||
key="create"
|
||||
variant="success"
|
||||
size="md"
|
||||
onClick={config.onCreateFolder}
|
||||
icon={FolderIcon}
|
||||
>
|
||||
New Folder
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[viewType, handleViewTypeChange, config],
|
||||
);
|
||||
|
||||
// Render grid item
|
||||
const renderGridItem = useCallback(
|
||||
(item) => {
|
||||
const itemType = getItemType(item);
|
||||
const isFolder = itemType === ITEM_TYPE_FOLDER;
|
||||
const name = item.name || item.encrypted_name || "Untitled";
|
||||
const isDecrypted = item._isDecrypted !== false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
isFolder ? handleFolderOpen(item) : handleItemClick(item, itemType)
|
||||
}
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 aspect-square !border-4"
|
||||
>
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-3">
|
||||
{isFolder ? getFolderIcon(item) : getFileIcon(item)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3
|
||||
className={`text-sm font-semibold ${getThemeClasses("text-primary")} truncate w-full`}
|
||||
title={name}
|
||||
>
|
||||
{isDecrypted ? name : "🔒 Locked"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Item-specific info at bottom */}
|
||||
<div
|
||||
className={`text-xs ${getThemeClasses("text-muted")} text-center pt-2 border-t-4 ${getThemeClasses("border-secondary")}`}
|
||||
>
|
||||
{isFolder && item.file_count !== undefined && (
|
||||
<div>{item.file_count} file{item.file_count !== 1 ? 's' : ''}</div>
|
||||
)}
|
||||
{!isFolder && item.size && (
|
||||
<div>{formatFileSize(item.size)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
[
|
||||
getItemType,
|
||||
handleFolderOpen,
|
||||
handleItemClick,
|
||||
getFolderIcon,
|
||||
getFileIcon,
|
||||
getThemeClasses,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
// Render list item
|
||||
const renderListItem = useCallback(
|
||||
(item) => {
|
||||
const itemType = getItemType(item);
|
||||
const isFolder = itemType === ITEM_TYPE_FOLDER;
|
||||
const name = item.name || item.encrypted_name || "Untitled";
|
||||
const isDecrypted = item._isDecrypted !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
isFolder ? handleFolderOpen(item) : handleItemClick(item, itemType)
|
||||
}
|
||||
className={`flex items-center p-4 border-b ${getThemeClasses("border-secondary")} hover:${getThemeClasses("bg-hover")} cursor-pointer transition-colors`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 mr-4">
|
||||
{isFolder ? getFolderIcon(item) : getFileIcon(item)}
|
||||
</div>
|
||||
|
||||
{/* Name and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className={`text-base font-semibold ${getThemeClasses("text-primary")} truncate`}
|
||||
title={name}
|
||||
>
|
||||
{isDecrypted ? name : "🔒 Locked"}
|
||||
</h3>
|
||||
{item.description && isDecrypted && (
|
||||
<p
|
||||
className={`text-sm ${getThemeClasses("text-secondary")} truncate mt-1`}
|
||||
>
|
||||
{item.description || item.encrypted_description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div
|
||||
className={`flex-shrink-0 ml-4 text-sm ${getThemeClasses("text-muted")} text-right space-y-1`}
|
||||
>
|
||||
{isFolder && item.file_count !== undefined && (
|
||||
<div>{item.file_count} file{item.file_count !== 1 ? 's' : ''}</div>
|
||||
)}
|
||||
{!isFolder && item.size && <div>{formatFileSize(item.size)}</div>}
|
||||
{item.created_at && <div>{formatDate(item.created_at)}</div>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{config.renderItemActions && (
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{config.renderItemActions(item, itemType)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chevron for folders */}
|
||||
{isFolder && (
|
||||
<ChevronRightIcon
|
||||
className={`w-5 h-5 ml-2 ${getThemeClasses("text-muted")}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
getItemType,
|
||||
handleFolderOpen,
|
||||
handleItemClick,
|
||||
getFolderIcon,
|
||||
getFileIcon,
|
||||
getThemeClasses,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
// Render content
|
||||
const renderContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-8 w-8 border-b-2 ${getThemeClasses("border-primary")}`}
|
||||
></div>
|
||||
<span className={`ml-3 ${getThemeClasses("text-secondary")}`}>
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<FolderOpenIcon
|
||||
className={`mx-auto h-12 w-12 ${getThemeClasses("text-muted")}`}
|
||||
/>
|
||||
<h3
|
||||
className={`mt-2 text-sm font-medium ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{config.emptyStateTitle || "No items found"}
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
{config.emptyStateDescription || "This folder is empty."}
|
||||
</p>
|
||||
{config.showCreateButton && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={config.onCreateFolder}
|
||||
icon={FolderIcon}
|
||||
>
|
||||
Create Folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render based on view type
|
||||
if (viewType === VIEW_TYPE_GRID) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Folders Section */}
|
||||
{folders.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}
|
||||
>
|
||||
<FolderIcon className="w-5 h-5 mr-2" />
|
||||
Folders ({folders.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{folders.map(renderGridItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}
|
||||
>
|
||||
<DocumentIcon className="w-5 h-5 mr-2" />
|
||||
Files ({files.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{files.map(renderGridItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// List view
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Folders Section */}
|
||||
{folders.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center px-4`}
|
||||
>
|
||||
<FolderIcon className="w-5 h-5 mr-2" />
|
||||
Folders ({folders.length})
|
||||
</h2>
|
||||
<div className={`border ${getThemeClasses("border-secondary")} rounded-lg overflow-hidden`}>
|
||||
{folders.map(renderListItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center px-4`}
|
||||
>
|
||||
<DocumentIcon className="w-5 h-5 mr-2" />
|
||||
Files ({files.length})
|
||||
</h2>
|
||||
<div className={`border ${getThemeClasses("border-secondary")} rounded-lg overflow-hidden`}>
|
||||
{files.map(renderListItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
items,
|
||||
folders,
|
||||
files,
|
||||
viewType,
|
||||
config,
|
||||
getThemeClasses,
|
||||
renderGridItem,
|
||||
renderListItem,
|
||||
]);
|
||||
|
||||
// Initial load effect
|
||||
// NOTE: We only include "trigger" dependencies here, not fetchItems itself
|
||||
// fetchItems already captures all its dependencies via useCallback
|
||||
// Including fetchItems here would cause infinite loops
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[EntityFileView] useEffect triggered - fetching items");
|
||||
}
|
||||
fetchItems(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
currentCursor,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
refreshCounter,
|
||||
currentFolderId, // Re-fetch when folder changes
|
||||
// NOTE: fetchItems is NOT included to prevent infinite loops
|
||||
// fetchItems is stable via useCallback and captures all these dependencies
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${getThemeClasses("bg-page")}`}>
|
||||
{/* Decorative background elements */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className={`absolute -top-40 -right-40 w-80 h-80 ${getThemeClasses("decorative-primary")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute -bottom-40 -left-40 w-80 h-80 ${getThemeClasses("decorative-secondary")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-2000`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 ${getThemeClasses("decorative-accent")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-4000`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 lg:py-12">
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div
|
||||
className={`mb-6 sm:mb-8 p-4 ${getThemeClasses("success-bg")} border ${getThemeClasses("success-border")} rounded-lg flex items-center`}
|
||||
>
|
||||
<span className={`text-sm ${getThemeClasses("success-text")}`}>
|
||||
{successMessage}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSuccessMessageClose}
|
||||
className={`ml-auto ${getThemeClasses("success-text")} hover:opacity-75`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.general && (
|
||||
<div
|
||||
className={`mb-6 sm:mb-8 p-4 ${getThemeClasses("error-bg")} border ${getThemeClasses("error-border")} rounded-lg`}
|
||||
>
|
||||
<span className={`text-sm ${getThemeClasses("error-text")}`}>
|
||||
{errors.general}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Layout */}
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div
|
||||
className={`${getThemeClasses("bg-card")} shadow-xl rounded-2xl overflow-hidden border ${getThemeClasses("border-secondary")} hover:shadow-2xl transition-shadow duration-300`}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={`px-6 sm:px-8 py-6 border-b ${getThemeClasses("border-secondary")}`}
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
{config.breadcrumbItems && config.breadcrumbItems.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<nav className="flex items-center space-x-2 text-sm">
|
||||
{config.breadcrumbItems.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<ChevronRightIcon
|
||||
className={`w-4 h-4 ${getThemeClasses("text-muted")}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={item.onClick}
|
||||
className={`${
|
||||
index === config.breadcrumbItems.length - 1
|
||||
? getThemeClasses("text-primary") + " font-semibold"
|
||||
: getThemeClasses("link-primary") + " hover:underline"
|
||||
}`}
|
||||
>
|
||||
{index === 0 && <HomeIcon className="w-4 h-4 inline mr-1" />}
|
||||
{item.label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with actions */}
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
{/* Icon if provided */}
|
||||
{config.icon && (
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||
<config.icon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className={config.icon ? "" : "w-full"}>
|
||||
<h1
|
||||
className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}
|
||||
>
|
||||
{config.title || "File Manager"}
|
||||
</h1>
|
||||
{config.subtitle && (
|
||||
<p
|
||||
className={`mt-2 text-base sm:text-lg lg:text-xl ${getThemeClasses("text-secondary")} font-medium`}
|
||||
>
|
||||
{config.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-3">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter Component */}
|
||||
<SearchFilter {...searchFilterConfig} />
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="p-6">{renderContent()}</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(previousCursors.length > 0 || nextCursor) && (
|
||||
<div
|
||||
className={`px-6 py-4 border-t ${getThemeClasses("border-secondary")} flex items-center justify-between`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={previousCursors.length === 0}
|
||||
onClick={() => handlePageChange("previous")}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
Page {previousCursors.length + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!nextCursor}
|
||||
onClick={() => handlePageChange("next")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return "";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Add display name
|
||||
EntityFileView.displayName = "EntityFileView";
|
||||
|
||||
export default EntityFileView;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// File: src/components/UIX/EntityFileView/index.jsx
|
||||
export { default as EntityFileView } from "./EntityFileView.jsx";
|
||||
export { default } from "./EntityFileView.jsx";
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
// File: src/components/UIX/EntityListPage/EntityListPage.jsx
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
} from "react";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import {
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
HomeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DataList,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
useUIXTheme,
|
||||
DetailPageIcon,
|
||||
UIXThemeProvider,
|
||||
SearchFilter,
|
||||
Card,
|
||||
} from "../";
|
||||
|
||||
// Constants
|
||||
const VIEW_TYPE_TABULAR = "tabular";
|
||||
const VIEW_TYPE_GRID = "grid";
|
||||
|
||||
/**
|
||||
* EntityListPage - A reusable list page component for managing entities
|
||||
*/
|
||||
const EntityListPage = memo(({ config }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Refs for cleanup
|
||||
const isMounted = useRef(true);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
// List state
|
||||
const [entityList, setEntityList] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
// Pagination state (cursor-based)
|
||||
const [pageSize, setPageSize] = useState(config.defaultPageSize || 25);
|
||||
const [previousCursors, setPreviousCursors] = useState([]);
|
||||
const [currentCursor, setCurrentCursor] = useState("");
|
||||
const [nextCursor, setNextCursor] = useState("");
|
||||
|
||||
// Filter state
|
||||
const [sortBy, setSortBy] = useState(config.defaultSort || "name");
|
||||
const [sortOrder, setSortOrder] = useState(config.defaultSortOrder || "ASC");
|
||||
const [status, setStatus] = useState(config.defaultStatus || "1");
|
||||
const [type, setType] = useState(config.defaultType || "0");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [tempSearchQuery, setTempSearchQuery] = useState("");
|
||||
const [viewType, setViewType] = useState(
|
||||
config.defaultViewType || VIEW_TYPE_GRID,
|
||||
);
|
||||
|
||||
// Force refresh counter
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoized callbacks
|
||||
const onUnauthorized = useCallback(() => {
|
||||
navigate("/login?unauthorized=true");
|
||||
}, [navigate]);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPreviousCursors([]);
|
||||
setCurrentCursor("");
|
||||
setNextCursor("");
|
||||
}, []);
|
||||
|
||||
// Fetch entity list - properly memoized
|
||||
const fetchEntityList = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const params = config.buildParams({
|
||||
pageSize,
|
||||
currentCursor,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
});
|
||||
|
||||
const response = await config.fetchData(
|
||||
params,
|
||||
onUnauthorized,
|
||||
forceRefresh,
|
||||
);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (config.onFetchSuccess) {
|
||||
config.onFetchSuccess(response, setEntityList, setNextCursor);
|
||||
} else {
|
||||
setEntityList({
|
||||
results: response.results || [],
|
||||
count: response.count || 0,
|
||||
});
|
||||
setNextCursor(
|
||||
response.hasNextPage || response.nextCursor
|
||||
? response.nextCursor || ""
|
||||
: "",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (config.onFetchError) {
|
||||
config.onFetchError(error, setErrors);
|
||||
} else {
|
||||
setErrors({
|
||||
general: `Failed to load ${config.entityNamePlural.toLowerCase()}. Please try again.`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentCursor,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
config,
|
||||
onUnauthorized,
|
||||
],
|
||||
);
|
||||
|
||||
// Pagination handlers
|
||||
const handlePageChange = useCallback(
|
||||
(direction) => {
|
||||
if (direction === "next" && nextCursor) {
|
||||
setPreviousCursors((prev) => [...prev, currentCursor]);
|
||||
setCurrentCursor(nextCursor);
|
||||
} else if (direction === "previous" && previousCursors.length > 0) {
|
||||
setPreviousCursors((prev) => {
|
||||
const newPrev = [...prev];
|
||||
const previousCursor = newPrev.pop();
|
||||
setCurrentCursor(previousCursor);
|
||||
return newPrev;
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentCursor, nextCursor, previousCursors],
|
||||
);
|
||||
|
||||
// Search handler
|
||||
const handleSearch = useCallback(() => {
|
||||
setSearchQuery(tempSearchQuery);
|
||||
resetPagination();
|
||||
}, [tempSearchQuery, resetPagination]);
|
||||
|
||||
// Filter handlers
|
||||
const handleStatusChange = useCallback(
|
||||
(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(newType) => {
|
||||
setType(newType);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sortValue) => {
|
||||
const [field, order] = sortValue.split(",");
|
||||
setSortBy(field);
|
||||
setSortOrder(order);
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handlePageSizeChange = useCallback(
|
||||
(newPageSize) => {
|
||||
setPageSize(parseInt(newPageSize));
|
||||
resetPagination();
|
||||
},
|
||||
[resetPagination],
|
||||
);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setStatus(config.defaultStatus || "1");
|
||||
setType(config.defaultType || "0");
|
||||
setSortBy(config.defaultSort || "name");
|
||||
setSortOrder(config.defaultSortOrder || "ASC");
|
||||
setSearchQuery("");
|
||||
setTempSearchQuery("");
|
||||
resetPagination();
|
||||
setRefreshCounter((prev) => prev + 1);
|
||||
}, [config, resetPagination]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchEntityList(true);
|
||||
}, [fetchEntityList]);
|
||||
|
||||
const handleSuccessMessageClose = useCallback(() => {
|
||||
setSuccessMessage("");
|
||||
}, []);
|
||||
|
||||
// View type handlers
|
||||
const handleViewTypeChange = useCallback((newViewType) => {
|
||||
setViewType(newViewType);
|
||||
}, []);
|
||||
|
||||
// Navigation handlers
|
||||
const navigateToSearch = useCallback(() => {
|
||||
navigate(config.routes.search);
|
||||
}, [navigate, config.routes.search]);
|
||||
|
||||
const navigateToCreate = useCallback(() => {
|
||||
navigate(config.routes.create);
|
||||
}, [navigate, config.routes.create]);
|
||||
|
||||
const navigateToDetail = useCallback(
|
||||
(entityId) => {
|
||||
navigate(config.routes.detail.replace(":id", entityId));
|
||||
},
|
||||
[navigate, config.routes.detail],
|
||||
);
|
||||
|
||||
// Memoized configurations
|
||||
const searchFilterConfig = useMemo(
|
||||
() => ({
|
||||
searchTerm: searchQuery,
|
||||
tempSearchTerm: tempSearchQuery,
|
||||
onSearchTermChange: setTempSearchQuery,
|
||||
onSearch: handleSearch,
|
||||
searchPlaceholder:
|
||||
config.searchPlaceholder ||
|
||||
`Search ${config.entityNamePlural.toLowerCase()}...`,
|
||||
statusOptions: config.statusOptions || [],
|
||||
statusFilter: status,
|
||||
onStatusFilterChange: handleStatusChange,
|
||||
typeOptions: config.typeOptions || [],
|
||||
typeFilter: type,
|
||||
onTypeFilterChange: handleTypeChange,
|
||||
sortOptions: config.sortOptions || [],
|
||||
sortValue: `${sortBy},${sortOrder}`,
|
||||
onSortChange: handleSortChange,
|
||||
pageSizeOptions: config.pageSizeOptions || [],
|
||||
pageSize,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onClearFilters: handleClearFilters,
|
||||
onRefresh: handleRefresh,
|
||||
}),
|
||||
[
|
||||
searchQuery,
|
||||
tempSearchQuery,
|
||||
handleSearch,
|
||||
config,
|
||||
status,
|
||||
handleStatusChange,
|
||||
type,
|
||||
handleTypeChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
handleSortChange,
|
||||
pageSize,
|
||||
handlePageSizeChange,
|
||||
handleClearFilters,
|
||||
handleRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
currentPage: previousCursors.length + 1,
|
||||
totalCount: entityList?.count || 0,
|
||||
hasNextPage: !!nextCursor,
|
||||
onPageChange: (direction) => {
|
||||
if (direction > previousCursors.length + 1) {
|
||||
handlePageChange("next");
|
||||
} else {
|
||||
handlePageChange("previous");
|
||||
}
|
||||
},
|
||||
}),
|
||||
[previousCursors, entityList, nextCursor, handlePageChange],
|
||||
);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
icon: config.icon,
|
||||
title: config.emptyState?.title || `No ${config.entityNamePlural} Found`,
|
||||
description:
|
||||
searchQuery ||
|
||||
status !== (config.defaultStatus || "1") ||
|
||||
type !== (config.defaultType || "0")
|
||||
? config.emptyState?.filterDescription ||
|
||||
`No ${config.entityNamePlural.toLowerCase()} match your current filters. Try adjusting your search criteria.`
|
||||
: config.emptyState?.emptyDescription ||
|
||||
`No ${config.entityNamePlural.toLowerCase()} have been added yet.`,
|
||||
actionLabel: config.emptyState?.actionLabel || `Add ${config.entityName}`,
|
||||
onActionClick: navigateToCreate,
|
||||
isCreateAction: true,
|
||||
}),
|
||||
[config, searchQuery, status, type, navigateToCreate],
|
||||
);
|
||||
|
||||
// Memoized header actions
|
||||
const headerActions = useMemo(
|
||||
() => [
|
||||
<div key="view-toggle" className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={viewType === VIEW_TYPE_TABULAR ? "primary" : "ghost"}
|
||||
onClick={() => handleViewTypeChange(VIEW_TYPE_TABULAR)}
|
||||
size="sm"
|
||||
>
|
||||
<TableCellsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewType === VIEW_TYPE_GRID ? "primary" : "ghost"}
|
||||
onClick={() => handleViewTypeChange(VIEW_TYPE_GRID)}
|
||||
size="sm"
|
||||
>
|
||||
<Squares2X2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>,
|
||||
<Button
|
||||
key="search"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={navigateToSearch}
|
||||
icon={config.searchIcon}
|
||||
>
|
||||
Advanced Search
|
||||
</Button>,
|
||||
<Button
|
||||
key="add"
|
||||
variant="success"
|
||||
size="lg"
|
||||
onClick={navigateToCreate}
|
||||
icon={config.createIcon}
|
||||
>
|
||||
{config.createLabel || `Create ${config.entityName}`}
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
viewType,
|
||||
handleViewTypeChange,
|
||||
navigateToSearch,
|
||||
navigateToCreate,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
const headerConfig = useMemo(
|
||||
() => ({
|
||||
icon: config.icon,
|
||||
title: `${config.entityName} Management`,
|
||||
showHeader: true,
|
||||
subtitle: `Manage your ${config.entityNamePlural.toLowerCase()} and their information`,
|
||||
decorativeIcon: <DetailPageIcon icon={config.icon} />,
|
||||
actions: headerActions,
|
||||
}),
|
||||
[config, headerActions],
|
||||
);
|
||||
|
||||
// Initial load effect
|
||||
useEffect(() => {
|
||||
fetchEntityList(true);
|
||||
}, [
|
||||
currentCursor,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
status,
|
||||
type,
|
||||
searchQuery,
|
||||
refreshCounter,
|
||||
]);
|
||||
|
||||
// Handle success message from navigation state
|
||||
useEffect(() => {
|
||||
if (location.state?.successMessage) {
|
||||
setSuccessMessage(location.state.successMessage);
|
||||
window.history.replaceState({}, document.title);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
setSuccessMessage("");
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Memoized grid content renderer
|
||||
const renderGridContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-8 w-8 border-b-2 ${getThemeClasses("border-primary")}`}
|
||||
></div>
|
||||
<span className={`ml-3 ${getThemeClasses("text-secondary")}`}>
|
||||
Loading {config.entityNamePlural.toLowerCase()}...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entityList?.results?.length > 0) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{entityList.results.map((entity) =>
|
||||
config.renderGridItem ? (
|
||||
config.renderGridItem(entity, navigate)
|
||||
) : (
|
||||
<Card
|
||||
key={entity.id}
|
||||
onClick={() => navigateToDetail(entity.id)}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center">
|
||||
<config.icon
|
||||
className={`w-8 h-8 ${getThemeClasses("link-primary")} mr-3`}
|
||||
/>
|
||||
<div>
|
||||
<h3
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{entity.name ||
|
||||
entity.organizationName ||
|
||||
`${entity.firstName} ${entity.lastName}`}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<config.icon
|
||||
className={`mx-auto h-12 w-12 ${getThemeClasses("text-muted")}`}
|
||||
/>
|
||||
<h3
|
||||
className={`mt-2 text-sm font-medium ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{emptyStateConfig.title}
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
{emptyStateConfig.description}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={emptyStateConfig.onActionClick}
|
||||
icon={config.createIcon}
|
||||
>
|
||||
{emptyStateConfig.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
isLoading,
|
||||
entityList,
|
||||
config,
|
||||
getThemeClasses,
|
||||
emptyStateConfig,
|
||||
navigate,
|
||||
navigateToDetail,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={config.breadcrumbItems} />
|
||||
|
||||
{/* Conditional Rendering: DataList for table view, Custom Grid for card view */}
|
||||
{viewType === VIEW_TYPE_TABULAR ? (
|
||||
<DataList
|
||||
data={entityList?.results || []}
|
||||
columns={config.columns}
|
||||
isLoading={isLoading}
|
||||
errors={errors}
|
||||
successMessage={successMessage}
|
||||
onSuccessMessageClose={handleSuccessMessageClose}
|
||||
searchFilter={searchFilterConfig}
|
||||
pagination={paginationConfig}
|
||||
emptyState={emptyStateConfig}
|
||||
header={headerConfig}
|
||||
/>
|
||||
) : (
|
||||
/* Standalone Grid View */
|
||||
<div
|
||||
className={`min-h-screen ${getThemeClasses("bg-gradient-primary")}`}
|
||||
>
|
||||
{/* Decorative background elements */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className={`absolute -top-40 -right-40 w-80 h-80 ${getThemeClasses("decorative-primary")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute -bottom-40 -left-40 w-80 h-80 ${getThemeClasses("decorative-secondary")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-2000`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 ${getThemeClasses("decorative-accent")} rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-4000`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 lg:py-12">
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div
|
||||
className={`mb-6 sm:mb-8 p-4 ${getThemeClasses("success-bg")} border ${getThemeClasses("success-border")} rounded-lg flex items-center`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${getThemeClasses("success-text")}`}
|
||||
>
|
||||
{successMessage}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSuccessMessageClose}
|
||||
className={`ml-auto ${getThemeClasses("success-text")} hover:opacity-75`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Layout */}
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div
|
||||
className={`${getThemeClasses("bg-card")} shadow-xl rounded-2xl overflow-hidden border ${getThemeClasses("border-secondary")} hover:shadow-2xl transition-shadow duration-300`}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={`px-6 sm:px-8 py-6 border-b ${getThemeClasses("border-secondary")}`}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-start lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}
|
||||
>
|
||||
<config.icon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
<div className="text-center lg:text-left">
|
||||
<h1
|
||||
className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}
|
||||
>
|
||||
{config.entityName} Management
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-3">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter Component */}
|
||||
<SearchFilter {...searchFilterConfig} />
|
||||
|
||||
{/* Grid Content Section */}
|
||||
<div className="p-6">{renderGridContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display name
|
||||
EntityListPage.displayName = "EntityListPage";
|
||||
|
||||
export default EntityListPage;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// File: src/components/UIX/EntityListPage/index.jsx
|
||||
|
||||
export { default } from "./EntityListPage";
|
||||
export { default as EntityListPage } from "./EntityListPage";
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
// File: src/components/UIX/EntityReportDetail/EntityReportDetail.jsx
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Alert,
|
||||
useUIXTheme,
|
||||
} from "../";
|
||||
import {
|
||||
HomeIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
DocumentArrowDownIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* EntityReportDetail - A reusable whole-page component for report detail pages
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {string} props.reportTitle - Title of the report (e.g., "Due Service Fees Report")
|
||||
* @param {string} props.reportDescription - Description of the report
|
||||
* @param {string} props.reportBreadcrumbLabel - Label for breadcrumb (e.g., "Due Service Fees")
|
||||
* @param {React.Component} props.icon - HeroIcon component for the report
|
||||
* @param {React.ReactNode} props.children - Form content to render inside the card
|
||||
* @param {Array} props.recentDownloads - Array of recent download items
|
||||
* @param {boolean} props.showSuccess - Whether to show success message
|
||||
* @param {Object} props.errors - Error object for display
|
||||
* @param {string} props.infoMessage - Info alert message to display
|
||||
* @param {Function} props.onDismissSuccess - Callback when success message is dismissed
|
||||
* @param {Function} props.onDismissErrors - Callback when errors are dismissed
|
||||
* @param {string} props.reportId - Optional report ID for filtering recent downloads
|
||||
* @param {string} props.reportType - Optional report type for filtering recent downloads
|
||||
*/
|
||||
const EntityReportDetail = memo(function EntityReportDetail({
|
||||
reportTitle,
|
||||
reportDescription,
|
||||
reportBreadcrumbLabel,
|
||||
icon: IconComponent,
|
||||
children,
|
||||
recentDownloads = [],
|
||||
showSuccess = false,
|
||||
errors = {},
|
||||
infoMessage = "",
|
||||
onDismissSuccess,
|
||||
onDismissErrors,
|
||||
reportId,
|
||||
reportType,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/admin/dashboard",
|
||||
icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
label: "Reports",
|
||||
to: "/admin/reports",
|
||||
icon: ChartBarIcon,
|
||||
},
|
||||
{
|
||||
label: reportBreadcrumbLabel || reportTitle,
|
||||
icon: IconComponent,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter recent downloads if reportId or reportType provided
|
||||
const filteredDownloads = recentDownloads.filter((item) => {
|
||||
if (reportId && reportType) {
|
||||
return item.reportId === reportId || item.reportType === reportType;
|
||||
}
|
||||
if (reportId) {
|
||||
return item.reportId === reportId;
|
||||
}
|
||||
if (reportType) {
|
||||
return item.reportType === reportType;
|
||||
}
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Report Form Card */}
|
||||
<div className={`${getThemeClasses("bg-card")} shadow-lg rounded-lg overflow-hidden border ${getThemeClasses("card-border")}`}>
|
||||
{/* Card Header */}
|
||||
<div className={`px-6 py-4 ${getThemeClasses("bg-gradient-header")} border-b ${getThemeClasses("border-color")}`}>
|
||||
<div className="flex items-center">
|
||||
<IconComponent className={`w-6 h-6 ${getThemeClasses("text-success")} mr-3`} />
|
||||
<div>
|
||||
<h1 className={`text-xl font-semibold ${getThemeClasses("text-header")}`}>
|
||||
{reportTitle}
|
||||
</h1>
|
||||
<p className={`text-sm ${getThemeClasses("text-header-secondary")} mt-1`}>
|
||||
{reportDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-6">
|
||||
{/* Success Message */}
|
||||
{showSuccess && (
|
||||
<Alert
|
||||
type="success"
|
||||
message="Report downloaded successfully! Check your downloads folder."
|
||||
className="mb-6"
|
||||
dismissible={!!onDismissSuccess}
|
||||
onDismiss={onDismissSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<Alert
|
||||
type="error"
|
||||
message="There were errors with your submission:"
|
||||
dismissible={!!onDismissErrors}
|
||||
onDismiss={onDismissErrors}
|
||||
/>
|
||||
<ul className={`list-disc list-inside mt-2 text-sm ${getThemeClasses("text-error")}`}>
|
||||
{Object.entries(errors).map(([field, message]) => (
|
||||
<li key={field}>
|
||||
<strong>{field}:</strong> {typeof message === "string" ? message : "Invalid value"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Alert */}
|
||||
{infoMessage && (
|
||||
<Alert
|
||||
type="info"
|
||||
message={infoMessage}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form Content - Passed as children */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Downloads */}
|
||||
{filteredDownloads.length > 0 && (
|
||||
<div className={`${getThemeClasses("bg-card")} shadow-lg rounded-lg overflow-hidden border ${getThemeClasses("card-border")}`}>
|
||||
<div className={`px-6 py-4 ${getThemeClasses("bg-gradient-header")} border-b ${getThemeClasses("border-color")}`}>
|
||||
<div className="flex items-center">
|
||||
<ClockIcon className={`w-5 h-5 ${getThemeClasses("text-header")} mr-2`} />
|
||||
<h2 className={`text-lg font-semibold ${getThemeClasses("text-header")}`}>
|
||||
Recent Downloads
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredDownloads.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 ${getThemeClasses("bg-secondary")} rounded-lg hover:shadow-md transition-all duration-200 border ${getThemeClasses("border-color")}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${getThemeClasses("text-primary")} truncate`}>
|
||||
{item.filename}
|
||||
</p>
|
||||
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-1`}>
|
||||
{new Date(item.downloadedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<DocumentArrowDownIcon className={`w-4 h-4 ${getThemeClasses("text-secondary")} flex-shrink-0 ml-2`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EntityReportDetail.displayName = "EntityReportDetail";
|
||||
|
||||
export default EntityReportDetail;
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
# EntityReportDetail Component
|
||||
|
||||
A reusable whole-page UIX component for report detail pages. Provides a standardized layout with breadcrumb navigation, form card, success/error handling, and recent downloads section.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Full-page layout with theme support
|
||||
- ✅ Automatic breadcrumb generation
|
||||
- ✅ Built-in success/error message handling
|
||||
- ✅ Recent downloads section with filtering
|
||||
- ✅ Info message support
|
||||
- ✅ Flexible form content via children
|
||||
- ✅ Responsive grid layout for downloads
|
||||
- ✅ Theme-aware styling throughout
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```jsx
|
||||
import React, { useState } from "react";
|
||||
import { EntityReportDetail, Button, Input, UIXThemeProvider } from "components/UIX";
|
||||
import { BanknotesIcon, CalendarIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
function MyReportPage() {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const recentDownloads = []; // Get from your service
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<EntityReportDetail
|
||||
reportTitle="Due Service Fees Report"
|
||||
reportDescription="Generate a report of outstanding service fees"
|
||||
reportBreadcrumbLabel="Due Service Fees"
|
||||
icon={BanknotesIcon}
|
||||
showSuccess={showSuccess}
|
||||
errors={errors}
|
||||
infoMessage="This report generates a CSV file with all outstanding fees."
|
||||
recentDownloads={recentDownloads}
|
||||
reportId={1}
|
||||
onDismissSuccess={() => setShowSuccess(false)}
|
||||
onDismissErrors={() => setErrors({})}
|
||||
>
|
||||
{/* Your form content goes here */}
|
||||
<form className="space-y-6">
|
||||
<Input
|
||||
label="From Date"
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={setFromDate}
|
||||
icon={CalendarIcon}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="To Date"
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={setToDate}
|
||||
icon={CalendarIcon}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="success">
|
||||
Download Report
|
||||
</Button>
|
||||
</form>
|
||||
</EntityReportDetail>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `reportTitle` | `string` | ✅ Yes | - | Title displayed in the card header |
|
||||
| `reportDescription` | `string` | ✅ Yes | - | Description text below the title |
|
||||
| `reportBreadcrumbLabel` | `string` | ❌ No | `reportTitle` | Label for the breadcrumb (defaults to reportTitle) |
|
||||
| `icon` | `React.Component` | ✅ Yes | - | HeroIcon component for the report (e.g., `BanknotesIcon`) |
|
||||
| `children` | `React.ReactNode` | ✅ Yes | - | Form content to render inside the card |
|
||||
| `recentDownloads` | `Array` | ❌ No | `[]` | Array of recent download objects |
|
||||
| `showSuccess` | `boolean` | ❌ No | `false` | Whether to show success message |
|
||||
| `errors` | `Object` | ❌ No | `{}` | Error object for display |
|
||||
| `infoMessage` | `string` | ❌ No | `""` | Info alert message to display |
|
||||
| `onDismissSuccess` | `Function` | ❌ No | - | Callback when success message is dismissed |
|
||||
| `onDismissErrors` | `Function` | ❌ No | - | Callback when errors are dismissed |
|
||||
| `reportId` | `string\|number` | ❌ No | - | Report ID for filtering recent downloads |
|
||||
| `reportType` | `string` | ❌ No | - | Report type for filtering recent downloads |
|
||||
|
||||
### Recent Downloads Array Format
|
||||
|
||||
Each item in the `recentDownloads` array should have:
|
||||
|
||||
```javascript
|
||||
{
|
||||
filename: "report-2024-01-15.csv", // Display name
|
||||
downloadedAt: "2024-01-15T10:30:00Z", // ISO date string
|
||||
reportId: 1, // Optional: for filtering
|
||||
reportType: "Due Service Fees" // Optional: for filtering
|
||||
}
|
||||
```
|
||||
|
||||
### Error Object Format
|
||||
|
||||
The `errors` object should be a key-value map:
|
||||
|
||||
```javascript
|
||||
{
|
||||
fromDate: "Start date is required",
|
||||
toDate: "End date must be after start date",
|
||||
general: "Failed to generate report"
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Structure
|
||||
|
||||
The component creates this structure:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Breadcrumb: Dashboard > Reports > ... │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 📊 Report Title │ │
|
||||
│ │ Report Description │ │
|
||||
│ ├─────────────────────────────────────┤ │
|
||||
│ │ [Success/Error Messages] │ │
|
||||
│ │ [Info Message] │ │
|
||||
│ │ │ │
|
||||
│ │ {children - Your Form Content} │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🕒 Recent Downloads │ │
|
||||
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
|
||||
│ │ │file1│ │file2│ │file3│ │ │
|
||||
│ │ └─────┘ └─────┘ └─────┘ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Styling & Themes
|
||||
|
||||
The component is fully theme-aware and uses:
|
||||
- `bg-gradient-primary` for page background
|
||||
- `bg-card` and `card-border` for card styling
|
||||
- `bg-gradient-header` for card headers
|
||||
- `text-primary`, `text-secondary` for text
|
||||
- `text-success`, `text-error` for status colors
|
||||
|
||||
All colors automatically adapt to the active theme (blue, red, purple, green, charcoal).
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Form Structure
|
||||
|
||||
Wrap your form content in a `<form>` tag and use UIX Input/Select components:
|
||||
|
||||
```jsx
|
||||
<EntityReportDetail {...props}>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input ... />
|
||||
<Select ... />
|
||||
<div className="flex justify-between pt-6 border-t">
|
||||
<Button variant="secondary">Back</Button>
|
||||
<Button variant="success">Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</EntityReportDetail>
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
Use proper error structure and dismiss callbacks:
|
||||
|
||||
```jsx
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// In your submit handler:
|
||||
try {
|
||||
await submitReport();
|
||||
} catch (error) {
|
||||
if (typeof error === "object") {
|
||||
setErrors(error);
|
||||
} else {
|
||||
setErrors({ general: "Failed to submit" });
|
||||
}
|
||||
}
|
||||
|
||||
// In component:
|
||||
<EntityReportDetail
|
||||
errors={errors}
|
||||
onDismissErrors={() => setErrors({})}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. Success Messages
|
||||
|
||||
Show success temporarily with auto-dismiss:
|
||||
|
||||
```jsx
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// After successful submission:
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => setShowSuccess(false), 5000);
|
||||
|
||||
// In component:
|
||||
<EntityReportDetail
|
||||
showSuccess={showSuccess}
|
||||
onDismissSuccess={() => setShowSuccess(false)}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
### 4. Recent Downloads
|
||||
|
||||
Fetch from your report service and filter by ID/type:
|
||||
|
||||
```jsx
|
||||
const recentDownloads = reportManager.getReportHistory();
|
||||
|
||||
<EntityReportDetail
|
||||
recentDownloads={recentDownloads}
|
||||
reportId={1}
|
||||
reportType="Due Service Fees"
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
The component automatically:
|
||||
- Filters by `reportId` OR `reportType` if provided
|
||||
- Limits to 5 most recent downloads
|
||||
- Hides the section if no downloads exist
|
||||
|
||||
## Integration with Services
|
||||
|
||||
### Expected Service Methods
|
||||
|
||||
Your report service should provide:
|
||||
|
||||
```javascript
|
||||
class ReportManager {
|
||||
// Get recent download history
|
||||
getReportHistory() {
|
||||
// Returns array of download objects
|
||||
}
|
||||
|
||||
// Validate report parameters
|
||||
validateReportParams(fromDate, toDate) {
|
||||
// Returns error object or empty object
|
||||
}
|
||||
|
||||
// Download report
|
||||
async downloadReport(params, onUnauthorized) {
|
||||
// Triggers file download
|
||||
}
|
||||
|
||||
// Get/set preferences
|
||||
getReportPreferences() {
|
||||
// Returns saved preferences
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
See [example-usage.jsx](./example-usage.jsx) for a complete working example with:
|
||||
- Form state management
|
||||
- Date range inputs
|
||||
- Status filter select
|
||||
- Submit handler with validation
|
||||
- Success/error handling
|
||||
- Recent downloads integration
|
||||
- Navigation callbacks
|
||||
|
||||
## Migration from Legacy Pattern
|
||||
|
||||
### Before (Manual Layout):
|
||||
|
||||
```jsx
|
||||
<div className="container">
|
||||
<nav>...</nav>
|
||||
<Card>
|
||||
<div className="header">...</div>
|
||||
<div className="body">
|
||||
{showSuccess && <Alert />}
|
||||
{errors && <Alert />}
|
||||
<form>...</form>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>Recent Downloads</Card>
|
||||
</div>
|
||||
```
|
||||
|
||||
### After (Using EntityReportDetail):
|
||||
|
||||
```jsx
|
||||
<EntityReportDetail
|
||||
reportTitle="..."
|
||||
reportDescription="..."
|
||||
icon={Icon}
|
||||
showSuccess={showSuccess}
|
||||
errors={errors}
|
||||
recentDownloads={downloads}
|
||||
>
|
||||
<form>...</form>
|
||||
</EntityReportDetail>
|
||||
```
|
||||
|
||||
## Related Components
|
||||
|
||||
- `EntityListPage` - For entity list pages
|
||||
- `EntityUpdatePage` - For entity update pages
|
||||
- `SearchCriteriaPage` - For search criteria pages
|
||||
- `Breadcrumb` - Used internally for navigation
|
||||
- `Alert` - Used internally for messages
|
||||
|
||||
## Browser Support
|
||||
|
||||
Supports all modern browsers. Uses:
|
||||
- CSS Grid for responsive layouts
|
||||
- Flexbox for internal alignment
|
||||
- CSS transitions for hover effects
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
// File: src/components/UIX/EntityReportDetail/example-usage.jsx
|
||||
// Example of how to use EntityReportDetail component in a report page
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useReportManager } from "../../../services/Services";
|
||||
import { ORDER_STATUS_FILTER_OPTIONS } from "../../../constants/FieldOptions";
|
||||
import {
|
||||
EntityReportDetail,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
UIXThemeProvider,
|
||||
useUIXTheme,
|
||||
} from "../../../components/UIX";
|
||||
import {
|
||||
BanknotesIcon,
|
||||
CalendarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
function ExampleReportPage() {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<ExampleReportPageContent />
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleReportPageContent() {
|
||||
const navigate = useNavigate();
|
||||
const reportManager = useReportManager();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Form state
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const [jobStatus, setJobStatus] = useState("0");
|
||||
|
||||
// UI state
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
const preferences = reportManager.getReportPreferences();
|
||||
if (preferences?.lastDueServiceFeesReport) {
|
||||
setJobStatus(String(preferences.lastDueServiceFeesReport.jobStatus || 0));
|
||||
}
|
||||
|
||||
// Set default dates (last 30 days)
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
setToDate(today.toISOString().split("T")[0]);
|
||||
setFromDate(thirtyDaysAgo.toISOString().split("T")[0]);
|
||||
}, [reportManager]);
|
||||
|
||||
// Handle unauthorized access
|
||||
const onUnauthorized = () => {
|
||||
navigate("/login?unauthorized=true");
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Submitting report", { fromDate, toDate, jobStatus });
|
||||
}
|
||||
|
||||
// Convert string dates to Date objects
|
||||
const fromDateObj = fromDate ? new Date(fromDate) : null;
|
||||
const toDateObj = toDate ? new Date(toDate) : null;
|
||||
|
||||
// Validate
|
||||
const validationErrors = reportManager.validateDueServiceFeesReportParams(
|
||||
fromDateObj,
|
||||
toDateObj,
|
||||
);
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear errors and start submission
|
||||
setErrors({});
|
||||
setIsSubmitting(true);
|
||||
setShowSuccess(false);
|
||||
|
||||
try {
|
||||
// Download the report
|
||||
await reportManager.downloadDueServiceFeesReport(
|
||||
fromDateObj,
|
||||
toDateObj,
|
||||
parseInt(jobStatus),
|
||||
onUnauthorized,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
setShowSuccess(true);
|
||||
|
||||
// Hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
}, 5000);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Report downloaded successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Error downloading report", error);
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (typeof error === "object" && error !== null) {
|
||||
setErrors(error);
|
||||
} else {
|
||||
setErrors({
|
||||
general: "Failed to download report. Please try again.",
|
||||
});
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get recent downloads from history
|
||||
const recentDownloads = reportManager.getReportHistory();
|
||||
|
||||
return (
|
||||
<EntityReportDetail
|
||||
reportTitle="Due Service Fees Report"
|
||||
reportDescription="Generate a report of outstanding service fees for facilitators"
|
||||
reportBreadcrumbLabel="Due Service Fees"
|
||||
icon={BanknotesIcon}
|
||||
showSuccess={showSuccess}
|
||||
errors={errors}
|
||||
infoMessage="This report will generate a CSV file containing all work orders with outstanding service fees within the specified date range. The dates refer to the assignment date of the work orders."
|
||||
recentDownloads={recentDownloads}
|
||||
reportId={1}
|
||||
reportType="Due Service Fees"
|
||||
onDismissSuccess={() => setShowSuccess(false)}
|
||||
onDismissErrors={() => setErrors({})}
|
||||
>
|
||||
{/* Form content goes here as children */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Date Range Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="From Date"
|
||||
name="fromDate"
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(value) => setFromDate(value)}
|
||||
error={errors.fromDate}
|
||||
required
|
||||
icon={CalendarIcon}
|
||||
helperText="Start date for the report (assignment date)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="To Date"
|
||||
name="toDate"
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(value) => setToDate(value)}
|
||||
error={errors.toDate}
|
||||
required
|
||||
icon={CalendarIcon}
|
||||
helperText="End date for the report (assignment date)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Job Status Field */}
|
||||
<Select
|
||||
label="Job Status Filter"
|
||||
value={jobStatus}
|
||||
onChange={(value) => setJobStatus(value)}
|
||||
options={ORDER_STATUS_FILTER_OPTIONS.map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
error={errors.jobStatus}
|
||||
helperText="Filter the report by specific job status or select 'All' for all statuses"
|
||||
/>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className={`flex flex-col sm:flex-row items-center justify-between pt-6 border-t ${getThemeClasses("border-color")} gap-3`}>
|
||||
<Button
|
||||
onClick={() => navigate("/admin/reports")}
|
||||
variant="secondary"
|
||||
>
|
||||
Back to Reports
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="success"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
loadingText="Generating..."
|
||||
>
|
||||
Download Report
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</EntityReportDetail>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExampleReportPage;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// File: src/components/UIX/EntityReportDetail/index.jsx
|
||||
|
||||
export { default } from "./EntityReportDetail";
|
||||
export { default as EntityReportDetail } from "./EntityReportDetail";
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/EntityUpdatePage.jsx
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
FormCard,
|
||||
FormSection,
|
||||
Button,
|
||||
Breadcrumb,
|
||||
PageHeader,
|
||||
Tabs,
|
||||
UIXThemeProvider,
|
||||
} from "../index";
|
||||
import { useUIXTheme } from "../themes/useUIXTheme.jsx";
|
||||
|
||||
// Conditional logging for development only
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
const log = (...args) => DEBUG && console.log(...args);
|
||||
const error = (...args) => console.error(...args); // Keep errors in production
|
||||
const warn = (...args) => DEBUG && console.warn(...args);
|
||||
|
||||
/**
|
||||
* Reusable Entity Update Page Component
|
||||
* Generic update page that can be configured for any entity type
|
||||
*
|
||||
* @param {Object} config - Configuration object for the entity
|
||||
* @param {string} config.entityName - Display name (e.g., "Staff Member", "Organization")
|
||||
* @param {string} config.entityType - Entity type for routes (e.g., "staff", "organization")
|
||||
* @param {string} config.idParam - URL parameter name for entity ID (e.g., "aid", "id")
|
||||
* @param {React.Component} config.icon - Icon component for headers/breadcrumbs
|
||||
* @param {Object} config.manager - Entity manager with CRUD methods
|
||||
* @param {Array} config.breadcrumbItems - Custom breadcrumb configuration
|
||||
* @param {Array} config.tabItems - Tab navigation configuration
|
||||
* @param {Array} config.formSections - Form sections configuration
|
||||
* @param {Function} config.validateForm - Custom validation function
|
||||
* @param {Function} config.formatDataForSubmit - Format data before submission
|
||||
* @param {Function} config.formatDataFromResponse - Format data from API response
|
||||
* @param {Function} config.onUnauthorized - Unauthorized access handler
|
||||
*/
|
||||
function EntityUpdatePage({ config }) {
|
||||
log("====== EntityUpdatePage: COMPONENT RENDER START ======");
|
||||
log("EntityUpdatePage: Received config:", {
|
||||
entityName: config.entityName,
|
||||
idParam: config.idParam,
|
||||
hasManager: !!config.manager,
|
||||
hasGetDetail: !!config.manager?.getDetail,
|
||||
hasFormatDataFromResponse: !!config.formatDataFromResponse,
|
||||
});
|
||||
|
||||
const { [config.idParam]: entityId } = useParams();
|
||||
log("EntityUpdatePage: Extracted entityId from params:", entityId);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Use refs to track component lifecycle and cleanup
|
||||
const isMountedRef = useRef(true);
|
||||
const abortControllerRef = useRef(null);
|
||||
const navigationTimeoutRef = useRef(null);
|
||||
const alertTimeoutRef = useRef(null);
|
||||
|
||||
// State management
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [alert, setAlert] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [entity, setEntity] = useState(null);
|
||||
const [formData, setFormData] = useState(() => config.initialFormData || {});
|
||||
|
||||
log("EntityUpdatePage: Current state:", {
|
||||
isLoading,
|
||||
hasEntity: !!entity,
|
||||
hasErrors: Object.keys(errors).length > 0,
|
||||
hasAlert: !!alert,
|
||||
});
|
||||
|
||||
// Memoized unauthorized handler
|
||||
const onUnauthorized = useCallback(() => {
|
||||
if (config.onUnauthorized) {
|
||||
config.onUnauthorized();
|
||||
} else {
|
||||
navigate("/login?unauthorized=true");
|
||||
}
|
||||
}, [config.onUnauthorized, navigate]);
|
||||
|
||||
// Memoized alert setter with auto-cleanup
|
||||
const setAlertWithCleanup = useCallback((alertData) => {
|
||||
// Clear any existing alert timeout
|
||||
if (alertTimeoutRef.current) {
|
||||
clearTimeout(alertTimeoutRef.current);
|
||||
alertTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setAlert(alertData);
|
||||
|
||||
// Auto-clear success alerts after 5 seconds
|
||||
if (alertData && alertData.type === "success") {
|
||||
alertTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setAlert(null);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debug: Track isLoading changes
|
||||
useEffect(() => {
|
||||
log("EntityUpdatePage: *** isLoading STATE CHANGED ***:", isLoading);
|
||||
}, [isLoading]);
|
||||
|
||||
// Debug: Track formData changes
|
||||
useEffect(() => {
|
||||
log("EntityUpdatePage: *** formData STATE CHANGED ***:", formData);
|
||||
}, [formData]);
|
||||
|
||||
// Load entity data on mount
|
||||
useEffect(() => {
|
||||
log("EntityUpdatePage: useEffect TRIGGERED");
|
||||
log("EntityUpdatePage: entityId:", entityId);
|
||||
log("EntityUpdatePage: config.manager:", !!config.manager);
|
||||
log("EntityUpdatePage: config.manager.getDetail:", !!config.manager?.getDetail);
|
||||
|
||||
// IMPORTANT: Reset mounted flag at start of effect
|
||||
// This handles React 19 Strict Mode double-mounting
|
||||
isMountedRef.current = true;
|
||||
log("EntityUpdatePage: Set isMountedRef.current = true");
|
||||
|
||||
// Create new abort controller for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const fetchEntityDetail = async () => {
|
||||
log("EntityUpdatePage: fetchEntityDetail STARTING");
|
||||
|
||||
if (!entityId) {
|
||||
error("EntityUpdatePage: No entityId provided!");
|
||||
setAlertWithCleanup({
|
||||
type: "error",
|
||||
message: `Invalid ${config.entityName.toLowerCase()} ID`,
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
log("EntityUpdatePage: Setting isLoading to true");
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
log("EntityUpdatePage: About to call config.manager.getDetail");
|
||||
log("EntityUpdatePage: Calling with entityId:", entityId);
|
||||
|
||||
// Pass abort signal to manager if supported
|
||||
const response = await config.manager.getDetail(
|
||||
entityId,
|
||||
onUnauthorized,
|
||||
{ signal: abortControllerRef.current?.signal },
|
||||
);
|
||||
|
||||
log("EntityUpdatePage: API call completed");
|
||||
log("EntityUpdatePage: Response received:", response);
|
||||
log("EntityUpdatePage: isMountedRef.current:", isMountedRef.current);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
log("EntityUpdatePage: Component still mounted, processing response");
|
||||
setEntity(response);
|
||||
log("EntityUpdatePage: Entity set");
|
||||
|
||||
// Format data from response if formatter provided
|
||||
log("EntityUpdatePage: Has formatter?", !!config.formatDataFromResponse);
|
||||
const formatted = config.formatDataFromResponse
|
||||
? config.formatDataFromResponse(response)
|
||||
: response;
|
||||
|
||||
log("EntityUpdatePage: Formatted data:", formatted);
|
||||
log("EntityUpdatePage: Setting formData");
|
||||
setFormData(formatted);
|
||||
log("EntityUpdatePage: Setting isLoading to FALSE");
|
||||
setIsLoading(false);
|
||||
log("EntityUpdatePage: Load complete!");
|
||||
} else {
|
||||
warn("EntityUpdatePage: Component unmounted, skipping state updates");
|
||||
}
|
||||
} catch (error) {
|
||||
error("EntityUpdatePage: Error occurred:", error);
|
||||
error("EntityUpdatePage: Error name:", error.name);
|
||||
error("EntityUpdatePage: Error message:", error.message);
|
||||
error("EntityUpdatePage: Error stack:", error.stack);
|
||||
|
||||
// Ignore abort errors
|
||||
if (error.name === "AbortError") {
|
||||
log("EntityUpdatePage: Abort error, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
log("EntityUpdatePage: Setting error alert");
|
||||
setAlertWithCleanup({
|
||||
type: "error",
|
||||
message: `Failed to load ${config.entityName.toLowerCase()} details. Please try again.`,
|
||||
});
|
||||
log("EntityUpdatePage: Setting isLoading to FALSE (error case)");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log("EntityUpdatePage: About to call fetchEntityDetail");
|
||||
fetchEntityDetail();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
log("EntityUpdatePage: useEffect CLEANUP running");
|
||||
// Mark as unmounted for this effect run
|
||||
isMountedRef.current = false;
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
entityId,
|
||||
config.manager,
|
||||
config.entityName,
|
||||
config.formatDataFromResponse,
|
||||
onUnauthorized,
|
||||
setAlertWithCleanup,
|
||||
]);
|
||||
|
||||
// Memoized input change handler
|
||||
const handleInputChange = useCallback(
|
||||
(field, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// Clear specific field error if it exists
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
},
|
||||
[errors],
|
||||
);
|
||||
|
||||
// Memoized form submission handler
|
||||
const handleSubmit = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear any existing navigation timeout
|
||||
if (navigationTimeoutRef.current) {
|
||||
clearTimeout(navigationTimeoutRef.current);
|
||||
navigationTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setAlertWithCleanup(null);
|
||||
|
||||
// Validate form if validator provided
|
||||
const formErrors = config.validateForm
|
||||
? config.validateForm(formData)
|
||||
: {};
|
||||
if (Object.keys(formErrors).length > 0) {
|
||||
setErrors(formErrors);
|
||||
setAlertWithCleanup({
|
||||
type: "error",
|
||||
message: "Please correct the errors in the form before submitting.",
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
// Format data for submission if formatter provided
|
||||
const submitData = config.formatDataForSubmit
|
||||
? config.formatDataForSubmit(formData, entityId)
|
||||
: { ...formData, id: entityId };
|
||||
|
||||
try {
|
||||
await config.manager.update(entityId, submitData, onUnauthorized);
|
||||
|
||||
setAlertWithCleanup({
|
||||
type: "success",
|
||||
message: `${config.entityName} updated successfully!`,
|
||||
});
|
||||
|
||||
// Navigate after delay with cleanup
|
||||
navigationTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
navigate(`/admin/${config.entityType}/${entityId}`);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
// Handle errors
|
||||
if (error && typeof error === "object" && !error.message) {
|
||||
setErrors(error);
|
||||
setAlertWithCleanup({
|
||||
type: "error",
|
||||
message: `Failed to update ${config.entityName.toLowerCase()}. Please check the form and try again.`,
|
||||
});
|
||||
} else {
|
||||
setAlertWithCleanup({
|
||||
type: "error",
|
||||
message:
|
||||
error?.message ||
|
||||
"An unexpected error occurred. Please try again.",
|
||||
});
|
||||
}
|
||||
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, formData, entityId, navigate, onUnauthorized, setAlertWithCleanup],
|
||||
);
|
||||
|
||||
// Memoized breadcrumb items
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
if (config.breadcrumbItems) {
|
||||
return config.breadcrumbItems;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/admin/dashboard",
|
||||
icon: config.dashboardIcon,
|
||||
},
|
||||
{
|
||||
label: config.entityName + "s",
|
||||
to: `/admin/${config.entityType}`,
|
||||
icon: config.icon,
|
||||
},
|
||||
{
|
||||
label: "Detail",
|
||||
to: `/admin/${config.entityType}/${entityId}`,
|
||||
icon: config.detailIcon,
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
icon: config.updateIcon,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
}, [config, entityId]);
|
||||
|
||||
// Memoized tab items
|
||||
const tabItems = useMemo(() => {
|
||||
if (config.tabItems) {
|
||||
return config.tabItems.map((tab) => ({
|
||||
...tab,
|
||||
to: tab.to ? tab.to.replace(`{${config.idParam}}`, entityId) : tab.to,
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Summary",
|
||||
to: `/admin/${config.entityType}/${entityId}`,
|
||||
},
|
||||
{
|
||||
label: "Full Details",
|
||||
to: `/admin/${config.entityType}/${entityId}/detail`,
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
label: "Comments",
|
||||
to: `/admin/${config.entityType}/${entityId}/comments`,
|
||||
},
|
||||
{
|
||||
label: "Attachments",
|
||||
to: `/admin/${config.entityType}/${entityId}/attachments`,
|
||||
},
|
||||
];
|
||||
}, [config.tabItems, config.entityType, config.idParam, entityId]);
|
||||
|
||||
// Memoized alert close handler
|
||||
const handleAlertClose = useCallback(() => {
|
||||
setAlertWithCleanup(null);
|
||||
}, [setAlertWithCleanup]);
|
||||
|
||||
// Memoized back button handler
|
||||
const handleBackClick = useCallback(() => {
|
||||
navigate(`/admin/${config.entityType}/${entityId}`);
|
||||
}, [navigate, config.entityType, entityId]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (navigationTimeoutRef.current) {
|
||||
clearTimeout(navigationTimeoutRef.current);
|
||||
}
|
||||
if (alertTimeoutRef.current) {
|
||||
clearTimeout(alertTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${getThemeClasses("border-primary")} mx-auto`}
|
||||
></div>
|
||||
<p
|
||||
className={`mt-4 text-sm sm:text-base ${getThemeClasses("text-muted")}`}
|
||||
>
|
||||
Loading {config.entityName.toLowerCase()} details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIXThemeProvider>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
icon={config.icon}
|
||||
title={`${config.entityName} - Update`}
|
||||
subtitle={`Update ${config.entityName.toLowerCase()} information`}
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
className={`${getThemeClasses("card-bg")} ${getThemeClasses("card-shadow")} rounded-lg mb-6`}
|
||||
>
|
||||
<Tabs tabs={tabItems} mode="routing" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormCard
|
||||
title={`Update ${config.entityName}`}
|
||||
icon={config.icon}
|
||||
maxWidth="full"
|
||||
>
|
||||
{/* Alert Display */}
|
||||
{alert && (
|
||||
<div
|
||||
className={`p-4 rounded-lg mb-6 ${
|
||||
alert.type === "success"
|
||||
? getThemeClasses("alert-success")
|
||||
: getThemeClasses("alert-error")
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{alert.type === "success" ? (
|
||||
<CheckCircleIcon
|
||||
className={`h-5 w-5 ${getThemeClasses("text-success-icon")}`}
|
||||
/>
|
||||
) : (
|
||||
<ExclamationTriangleIcon
|
||||
className={`h-5 w-5 ${getThemeClasses("text-error-icon")}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">{alert.message}</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAlertClose}
|
||||
className={`${getThemeClasses("text-muted")} hover:${getThemeClasses("text-default")} transition-colors`}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Form Sections */}
|
||||
{config.formSections &&
|
||||
config.formSections.map((SectionComponent, index) => (
|
||||
<React.Fragment key={`form-section-${index}`}>
|
||||
<SectionComponent
|
||||
formData={formData}
|
||||
errors={errors}
|
||||
onChange={handleInputChange}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div
|
||||
className={`flex justify-between items-center pt-6 border-t ${getThemeClasses("border-default")}`}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBackClick}
|
||||
icon={ChevronLeftIcon}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back to Detail
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSubmitting}
|
||||
icon={CheckCircleIcon}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</FormCard>
|
||||
</form>
|
||||
</div>
|
||||
</UIXThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoize the entire component to prevent unnecessary re-renders
|
||||
export default React.memo(EntityUpdatePage);
|
||||
|
|
@ -0,0 +1,739 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/DivisionFormSections.jsx
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
UserGroupIcon,
|
||||
MapPinIcon,
|
||||
TagIcon,
|
||||
CalendarIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
FormSection,
|
||||
FormRow,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
DateInput,
|
||||
MultiSelect,
|
||||
} from "../../index";
|
||||
import { useUIXTheme } from "../../themes/useUIXTheme.jsx";
|
||||
import { OrganizationSelect } from "../../../business/selects";
|
||||
import { useTagManager } from "../../../../services/Services";
|
||||
import {
|
||||
DIVISION_TYPE_OPTIONS,
|
||||
DIVISION_STATUS,
|
||||
DIVISION_STATUS_LABELS,
|
||||
COUNTRY_OPTIONS,
|
||||
getRegionOptions,
|
||||
getRegionLabel,
|
||||
PHONE_TYPE_OPTIONS,
|
||||
DEFAULT_VALUES,
|
||||
} from "../../../../constants/Division";
|
||||
|
||||
// Map division types to organization types - memoized constant
|
||||
const DIVISION_TYPE_TO_ORG_TYPE = Object.freeze({
|
||||
educational: "educational",
|
||||
corporate: "corporate",
|
||||
"non-profit": "non-profit",
|
||||
government: "government",
|
||||
});
|
||||
|
||||
// Default contact template - memoized constant
|
||||
const DEFAULT_CONTACT = Object.freeze({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
title: "",
|
||||
email: "",
|
||||
isOkToEmail: false,
|
||||
phone: "",
|
||||
phoneType: 0,
|
||||
phoneExtension: "",
|
||||
otherPhone: "",
|
||||
otherPhoneType: 0,
|
||||
otherPhoneExtension: "",
|
||||
});
|
||||
|
||||
// Generate unique ID for contacts
|
||||
const generateContactId = () =>
|
||||
`contact-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Basic Information Section
|
||||
export const DivisionBasicInfoSection = React.memo(
|
||||
function DivisionBasicInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handler for unauthorized access
|
||||
const handleUnauthorized = useCallback(() => {
|
||||
// This will be handled by the parent EntityUpdatePage
|
||||
console.log("Unauthorized access");
|
||||
}, []);
|
||||
|
||||
// Memoized handler for division type change
|
||||
const handleDivisionTypeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("divisionType", value);
|
||||
// Clear organization when type changes
|
||||
onChange("organizationId", "");
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Memoized handler for status change
|
||||
const handleStatusChange = useCallback(
|
||||
(value) => {
|
||||
onChange("status", parseInt(value));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Memoized status options
|
||||
const statusOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: DIVISION_STATUS.ACTIVE,
|
||||
label: DIVISION_STATUS_LABELS[DIVISION_STATUS.ACTIVE],
|
||||
},
|
||||
{
|
||||
value: DIVISION_STATUS.INACTIVE,
|
||||
label: DIVISION_STATUS_LABELS[DIVISION_STATUS.INACTIVE],
|
||||
},
|
||||
{
|
||||
value: DIVISION_STATUS.ARCHIVED,
|
||||
label: DIVISION_STATUS_LABELS[DIVISION_STATUS.ARCHIVED],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoized filtered division type options
|
||||
const filteredDivisionTypeOptions = useMemo(
|
||||
() => DIVISION_TYPE_OPTIONS.filter((opt) => opt.value),
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoized placeholder text
|
||||
const orgSelectPlaceholder = useMemo(() => {
|
||||
if (!formData.divisionType) return "Select organization (optional)";
|
||||
|
||||
const typeLabel =
|
||||
formData.divisionType === "non-profit"
|
||||
? "Non-Profit"
|
||||
: formData.divisionType.charAt(0).toUpperCase() +
|
||||
formData.divisionType.slice(1);
|
||||
|
||||
return `Select ${typeLabel} Organization`;
|
||||
}, [formData.divisionType]);
|
||||
|
||||
return (
|
||||
<FormSection title="Basic Information" icon={BuildingOffice2Icon}>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Division Name"
|
||||
value={formData.divisionName}
|
||||
onChange={(value) => onChange("divisionName", value)}
|
||||
error={errors.divisionName}
|
||||
required
|
||||
placeholder="Enter division name"
|
||||
/>
|
||||
<Input
|
||||
label="Division Short Name"
|
||||
value={formData.divisionShortName}
|
||||
onChange={(value) => onChange("divisionShortName", value)}
|
||||
error={errors.divisionShortName}
|
||||
placeholder="Enter short name (optional)"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
label="Division Type"
|
||||
value={formData.divisionType}
|
||||
onChange={handleDivisionTypeChange}
|
||||
options={filteredDivisionTypeOptions}
|
||||
error={errors.divisionType}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
value={formData.status}
|
||||
onChange={handleStatusChange}
|
||||
options={statusOptions}
|
||||
error={errors.status}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<div>
|
||||
<OrganizationSelect
|
||||
value={formData.organizationId}
|
||||
onChange={(value) => onChange("organizationId", value)}
|
||||
error={errors.organizationId}
|
||||
label="Host Organization"
|
||||
onUnauthorized={handleUnauthorized}
|
||||
type={
|
||||
formData.divisionType
|
||||
? DIVISION_TYPE_TO_ORG_TYPE[formData.divisionType]
|
||||
: null
|
||||
}
|
||||
placeholder={orgSelectPlaceholder}
|
||||
/>
|
||||
{formData.divisionType && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Only showing{" "}
|
||||
{formData.divisionType === "non-profit"
|
||||
? "non-profit"
|
||||
: formData.divisionType}{" "}
|
||||
organizations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DateInput
|
||||
label="Join Date"
|
||||
value={formData.joinDate}
|
||||
onChange={(value) => onChange("joinDate", value)}
|
||||
error={errors.joinDate}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Contact Information Section
|
||||
export const DivisionContactInfoSection = React.memo(
|
||||
function DivisionContactInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Initialize contacts with unique IDs
|
||||
const contacts = useMemo(() => {
|
||||
const existingContacts = formData.contacts || [];
|
||||
if (existingContacts.length === 0) {
|
||||
return [
|
||||
{
|
||||
...DEFAULT_CONTACT,
|
||||
id: generateContactId(),
|
||||
},
|
||||
];
|
||||
}
|
||||
// Ensure all contacts have IDs
|
||||
return existingContacts.map((contact) => ({
|
||||
...contact,
|
||||
id: contact.id || generateContactId(),
|
||||
}));
|
||||
}, [formData.contacts]);
|
||||
|
||||
// Memoized handler for contact changes
|
||||
const handleContactChange = useCallback(
|
||||
(index, field, value) => {
|
||||
const updatedContacts = [...contacts];
|
||||
updatedContacts[index] = {
|
||||
...updatedContacts[index],
|
||||
[field]: value,
|
||||
};
|
||||
onChange("contacts", updatedContacts);
|
||||
},
|
||||
[contacts, onChange],
|
||||
);
|
||||
|
||||
// Memoized handler for adding contacts
|
||||
const addContact = useCallback(() => {
|
||||
if (contacts.length >= DEFAULT_VALUES.MAX_CONTACTS) {
|
||||
return;
|
||||
}
|
||||
const newContacts = [
|
||||
...contacts,
|
||||
{
|
||||
...DEFAULT_CONTACT,
|
||||
id: generateContactId(),
|
||||
},
|
||||
];
|
||||
onChange("contacts", newContacts);
|
||||
}, [contacts, onChange]);
|
||||
|
||||
// Memoized handler for removing contacts
|
||||
const removeContact = useCallback(
|
||||
(index) => {
|
||||
if (contacts.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const newContacts = contacts.filter((_, i) => i !== index);
|
||||
onChange("contacts", newContacts);
|
||||
},
|
||||
[contacts, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Contact Information" icon={UserGroupIcon}>
|
||||
<div className="space-y-6">
|
||||
{contacts.map((contact, index) => (
|
||||
<ContactCard
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
index={index}
|
||||
errors={errors}
|
||||
canRemove={contacts.length > 1}
|
||||
onContactChange={handleContactChange}
|
||||
onRemove={() => removeContact(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{contacts.length < DEFAULT_VALUES.MAX_CONTACTS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addContact}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Another Contact
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Separate ContactCard component for better performance
|
||||
const ContactCard = React.memo(function ContactCard({
|
||||
contact,
|
||||
index,
|
||||
errors,
|
||||
canRemove,
|
||||
onContactChange,
|
||||
onRemove,
|
||||
}) {
|
||||
// Create handlers for each field to avoid recreating functions
|
||||
const createFieldHandler = useCallback(
|
||||
(field) => {
|
||||
return (value) => onContactChange(index, field, value);
|
||||
},
|
||||
[index, onContactChange],
|
||||
);
|
||||
|
||||
const handlePhoneTypeChange = useCallback(
|
||||
(value) => {
|
||||
onContactChange(index, "phoneType", parseInt(value));
|
||||
},
|
||||
[index, onContactChange],
|
||||
);
|
||||
|
||||
const handleOtherPhoneTypeChange = useCallback(
|
||||
(value) => {
|
||||
onContactChange(index, "otherPhoneType", parseInt(value));
|
||||
},
|
||||
[index, onContactChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
Contact {index + 1}
|
||||
</h4>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={contact.firstName}
|
||||
onChange={createFieldHandler("firstName")}
|
||||
error={errors[`contacts[${index}].firstName`]}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={contact.lastName}
|
||||
onChange={createFieldHandler("lastName")}
|
||||
error={errors[`contacts[${index}].lastName`]}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Title"
|
||||
value={contact.title}
|
||||
onChange={createFieldHandler("title")}
|
||||
placeholder="e.g., Manager, Director"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={contact.email}
|
||||
onChange={createFieldHandler("email")}
|
||||
error={errors[`contacts[${index}].email`]}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Phone"
|
||||
value={contact.phone}
|
||||
onChange={createFieldHandler("phone")}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
<Select
|
||||
label="Phone Type"
|
||||
value={contact.phoneType}
|
||||
onChange={handlePhoneTypeChange}
|
||||
options={PHONE_TYPE_OPTIONS}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{contact.phoneType === 2 && (
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Phone Extension"
|
||||
value={contact.phoneExtension}
|
||||
onChange={createFieldHandler("phoneExtension")}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
<div /> {/* Empty div to maintain grid */}
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Checkbox
|
||||
label="OK to Email"
|
||||
checked={contact.isOkToEmail}
|
||||
onChange={createFieldHandler("isOkToEmail")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Other Phone Section */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
Other Phone (Optional)
|
||||
</h5>
|
||||
<FormRow columns={3}>
|
||||
<Input
|
||||
label="Other Phone"
|
||||
value={contact.otherPhone}
|
||||
onChange={createFieldHandler("otherPhone")}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
<Select
|
||||
label="Other Phone Type"
|
||||
value={contact.otherPhoneType}
|
||||
onChange={handleOtherPhoneTypeChange}
|
||||
options={PHONE_TYPE_OPTIONS}
|
||||
/>
|
||||
{contact.otherPhoneType === 2 && (
|
||||
<Input
|
||||
label="Other Phone Extension"
|
||||
value={contact.otherPhoneExtension}
|
||||
onChange={createFieldHandler("otherPhoneExtension")}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Address Information Section
|
||||
export const DivisionAddressSection = React.memo(
|
||||
function DivisionAddressSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized region options and labels
|
||||
const regionOptions = useMemo(
|
||||
() => getRegionOptions(formData.country),
|
||||
[formData.country],
|
||||
);
|
||||
|
||||
const regionLabel = useMemo(
|
||||
() => getRegionLabel(formData.country),
|
||||
[formData.country],
|
||||
);
|
||||
|
||||
const shippingRegionOptions = useMemo(
|
||||
() =>
|
||||
formData.hasShippingAddress
|
||||
? getRegionOptions(formData.shippingCountry)
|
||||
: [],
|
||||
[formData.hasShippingAddress, formData.shippingCountry],
|
||||
);
|
||||
|
||||
const shippingRegionLabel = useMemo(
|
||||
() =>
|
||||
formData.hasShippingAddress
|
||||
? getRegionLabel(formData.shippingCountry)
|
||||
: "Region",
|
||||
[formData.hasShippingAddress, formData.shippingCountry],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Address Information" icon={MapPinIcon}>
|
||||
<div className="mb-6">
|
||||
<Checkbox
|
||||
label="Has shipping address different than mailing address"
|
||||
checked={formData.hasShippingAddress}
|
||||
onChange={(checked) => onChange("hasShippingAddress", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid ${formData.hasShippingAddress ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"} gap-8`}
|
||||
>
|
||||
{/* Mailing Address */}
|
||||
<div>
|
||||
{formData.hasShippingAddress && (
|
||||
<h4 className="text-base font-medium text-gray-900 mb-4">
|
||||
Mailing Address
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Country"
|
||||
value={formData.country}
|
||||
onChange={(value) => onChange("country", value)}
|
||||
options={COUNTRY_OPTIONS}
|
||||
error={errors.country}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={regionLabel}
|
||||
value={formData.region}
|
||||
onChange={(value) => onChange("region", value)}
|
||||
options={regionOptions}
|
||||
error={errors.region}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="City"
|
||||
value={formData.city}
|
||||
onChange={(value) => onChange("city", value)}
|
||||
error={errors.city}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Address Line 1"
|
||||
value={formData.addressLine1}
|
||||
onChange={(value) => onChange("addressLine1", value)}
|
||||
error={errors.addressLine1}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Address Line 2 (Optional)"
|
||||
value={formData.addressLine2}
|
||||
onChange={(value) => onChange("addressLine2", value)}
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Postal Code"
|
||||
value={formData.postalCode}
|
||||
onChange={(value) => onChange("postalCode", value)}
|
||||
error={errors.postalCode}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Address */}
|
||||
{formData.hasShippingAddress && (
|
||||
<div>
|
||||
<h4 className="text-base font-medium text-gray-900 mb-4">
|
||||
Shipping Address
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Shipping Name"
|
||||
value={formData.shippingName}
|
||||
onChange={(value) => onChange("shippingName", value)}
|
||||
placeholder="Company or recipient name"
|
||||
error={errors.shippingName}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Shipping Phone"
|
||||
value={formData.shippingPhone}
|
||||
onChange={(value) => onChange("shippingPhone", value)}
|
||||
placeholder="(555) 123-4567"
|
||||
error={errors.shippingPhone}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Country"
|
||||
value={formData.shippingCountry}
|
||||
onChange={(value) => onChange("shippingCountry", value)}
|
||||
options={COUNTRY_OPTIONS}
|
||||
error={errors.shippingCountry}
|
||||
required={formData.hasShippingAddress}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={shippingRegionLabel}
|
||||
value={formData.shippingRegion}
|
||||
onChange={(value) => onChange("shippingRegion", value)}
|
||||
options={shippingRegionOptions}
|
||||
error={errors.shippingRegion}
|
||||
required={formData.hasShippingAddress}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="City"
|
||||
value={formData.shippingCity}
|
||||
onChange={(value) => onChange("shippingCity", value)}
|
||||
error={errors.shippingCity}
|
||||
required={formData.hasShippingAddress}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Address Line 1"
|
||||
value={formData.shippingAddressLine1}
|
||||
onChange={(value) => onChange("shippingAddressLine1", value)}
|
||||
error={errors.shippingAddressLine1}
|
||||
required={formData.hasShippingAddress}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Address Line 2 (Optional)"
|
||||
value={formData.shippingAddressLine2}
|
||||
onChange={(value) => onChange("shippingAddressLine2", value)}
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Postal Code"
|
||||
value={formData.shippingPostalCode}
|
||||
onChange={(value) => onChange("shippingPostalCode", value)}
|
||||
error={errors.shippingPostalCode}
|
||||
required={formData.hasShippingAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Additional Information Section
|
||||
export const DivisionAdditionalInfoSection = React.memo(
|
||||
function DivisionAdditionalInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const tagManager = useTagManager();
|
||||
const [tagOptions, setTagOptions] = useState([]);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Load tag options with cleanup
|
||||
useEffect(() => {
|
||||
// Create new abort controller for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
console.log("DivisionAdditionalInfoSection: Loading tag options...");
|
||||
const options = await tagManager.getTagSelectOptions(() => {}, {
|
||||
signal: abortControllerRef.current?.signal,
|
||||
});
|
||||
|
||||
console.log("DivisionAdditionalInfoSection: Raw options received:", options);
|
||||
const formattedOptions = options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}));
|
||||
console.log("DivisionAdditionalInfoSection: Setting tagOptions to:", formattedOptions);
|
||||
setTagOptions(formattedOptions);
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (error.name === "AbortError") {
|
||||
console.log("DivisionAdditionalInfoSection: Tag loading aborted (expected on unmount)");
|
||||
return;
|
||||
}
|
||||
console.error("DivisionAdditionalInfoSection: Error loading tags:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadTags();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [tagManager]);
|
||||
|
||||
// Memoized handler for textarea change
|
||||
const handleCommentChange = useCallback(
|
||||
(e) => {
|
||||
onChange("additionalComment", e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log("DivisionAdditionalInfoSection: formData.tags:", formData.tags);
|
||||
console.log("DivisionAdditionalInfoSection: tagOptions:", tagOptions);
|
||||
console.log("DivisionAdditionalInfoSection: tagOptions loaded?", tagOptions.length > 0);
|
||||
}, [formData.tags, tagOptions]);
|
||||
|
||||
return (
|
||||
<FormSection title="Additional Information" icon={TagIcon}>
|
||||
<div className="space-y-6">
|
||||
<MultiSelect
|
||||
label="Tags (Optional)"
|
||||
value={formData.tags}
|
||||
onChange={(value) => onChange("tags", value)}
|
||||
options={tagOptions}
|
||||
placeholder="Select tags..."
|
||||
error={errors.tags}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium ${getThemeClasses("text-default")} mb-2`}
|
||||
>
|
||||
Additional Comments
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.additionalComment}
|
||||
onChange={handleCommentChange}
|
||||
rows={4}
|
||||
placeholder="Any additional information..."
|
||||
className={`block w-full px-3 py-2 border ${getThemeClasses("input-border")} rounded-lg ${getThemeClasses("focus-ring")} ${getThemeClasses("focus-border")}`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{formData.additionalComment?.length || 0}/
|
||||
{DEFAULT_VALUES.COMMENT_MAX_LENGTH} characters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/EventFormSections.jsx
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
UserGroupIcon,
|
||||
DocumentTextIcon,
|
||||
CogIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
BuildingOffice2Icon,
|
||||
AcademicCapIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { FormSection, FormRow, Input, Select, Checkbox, DateTime } from "../../index";
|
||||
import { useUIXTheme } from "../../themes/useUIXTheme.jsx";
|
||||
import { SpecializationCertificationPicker } from "../../../business/selects";
|
||||
|
||||
// Event status options - frozen constant
|
||||
const EVENT_STATUS_OPTIONS = Object.freeze([
|
||||
{ value: "1", label: "Active" },
|
||||
{ value: "2", label: "Archived" },
|
||||
]);
|
||||
|
||||
// SHSM options - frozen constant (boolean values as strings for Select component)
|
||||
const SHSM_OPTIONS = Object.freeze([
|
||||
{ value: "true", label: "Yes" },
|
||||
{ value: "false", label: "No" },
|
||||
]);
|
||||
|
||||
// Event Type Label Map
|
||||
const EVENT_TYPE_LABELS = {
|
||||
"1": "Conference",
|
||||
"2": "In-School",
|
||||
"3": "Field Trip",
|
||||
"4": "Virtual",
|
||||
};
|
||||
|
||||
// Basic Event Information Section
|
||||
export const EventBasicInfoSection = React.memo(function EventBasicInfoSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers for text areas
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e) => {
|
||||
onChange("description", e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAdditionalNotesChange = useCallback(
|
||||
(e) => {
|
||||
onChange("additionalNotes", e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Memoized handler for event name
|
||||
const handleEventNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("eventName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Get event type label
|
||||
const eventTypeLabel = formData.eventType ? EVENT_TYPE_LABELS[formData.eventType] || "Unknown" : "";
|
||||
|
||||
return (
|
||||
<FormSection title="Event Information" icon={CalendarDaysIcon}>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
id="eventName"
|
||||
name="eventName"
|
||||
label="Event Name"
|
||||
value={formData.eventName}
|
||||
onChange={handleEventNameChange}
|
||||
error={errors.eventName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="eventType"
|
||||
name="eventType"
|
||||
label="Event Type"
|
||||
value={eventTypeLabel}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className={`block text-sm font-medium ${getThemeClasses("text-default")} mb-2`}
|
||||
>
|
||||
Event Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleDescriptionChange}
|
||||
rows={3}
|
||||
className={`block w-full px-3 py-2 border ${getThemeClasses("input-border")} ${getThemeClasses("input-bg")} ${getThemeClasses("text-default")} rounded-lg ${getThemeClasses("focus-ring")} ${getThemeClasses("focus-border")}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="additionalNotes"
|
||||
className={`block text-sm font-medium ${getThemeClasses("text-default")} mb-2`}
|
||||
>
|
||||
Additional Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="additionalNotes"
|
||||
name="additionalNotes"
|
||||
value={formData.additionalNotes}
|
||||
onChange={handleAdditionalNotesChange}
|
||||
rows={2}
|
||||
className={`block w-full px-3 py-2 border ${getThemeClasses("input-border")} ${getThemeClasses("input-bg")} ${getThemeClasses("text-default")} rounded-lg ${getThemeClasses("focus-ring")} ${getThemeClasses("focus-border")}`}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Date & Time Information Section
|
||||
export const EventDateTimeSection = React.memo(function EventDateTimeSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Convert DateTime component format back to separate datetime-local formats
|
||||
const handleDateTimeChange = useCallback((value) => {
|
||||
if (!value?.date || !value?.startTime) {
|
||||
onChange("startDateTime", "");
|
||||
onChange("endDateTime", "");
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateTime = `${value.date}T${value.startTime}`;
|
||||
onChange("startDateTime", startDateTime);
|
||||
|
||||
if (value.endTime) {
|
||||
const endDateTime = `${value.date}T${value.endTime}`;
|
||||
onChange("endDateTime", endDateTime);
|
||||
} else {
|
||||
onChange("endDateTime", "");
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// Memoized datetime value - convert datetime-local formats to DateTime component format
|
||||
const dateTimeValue = useMemo(() => {
|
||||
if (!formData.startDateTime) return { date: "", startTime: "", endTime: "" };
|
||||
|
||||
const [startDate, startTime] = formData.startDateTime.split("T");
|
||||
let endTime = "";
|
||||
|
||||
if (formData.endDateTime) {
|
||||
const [, endTimeStr] = formData.endDateTime.split("T");
|
||||
endTime = endTimeStr || "";
|
||||
}
|
||||
|
||||
return {
|
||||
date: startDate || "",
|
||||
startTime: startTime || "",
|
||||
endTime: endTime
|
||||
};
|
||||
}, [formData.startDateTime, formData.endDateTime]);
|
||||
|
||||
// Get today's date for minDate
|
||||
const getTodayDate = useCallback(() => {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}, []);
|
||||
|
||||
// Calculate day of week from date
|
||||
const dayOfWeek = useMemo(() => {
|
||||
if (!formData.startDateTime) return "";
|
||||
const dateStr = formData.startDateTime.split("T")[0];
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
return days[date.getDay()];
|
||||
}, [formData.startDateTime]);
|
||||
|
||||
// Checkbox handlers
|
||||
const handleFacilitatorAMChange = useCallback(
|
||||
(e) => {
|
||||
onChange("facilitatorAM", e.target.checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleFacilitatorPMChange = useCallback(
|
||||
(e) => {
|
||||
onChange("facilitatorPM", e.target.checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
return (
|
||||
<FormSection title="Date & Time Information" icon={ClockIcon}>
|
||||
<FormRow columns={1}>
|
||||
<DateTime
|
||||
id="eventDateTime"
|
||||
label="Event Date & Time"
|
||||
value={dateTimeValue}
|
||||
onChange={handleDateTimeChange}
|
||||
error={errors.startDateTime || errors.endDateTime}
|
||||
required
|
||||
enableEndTime={true}
|
||||
minDate={getTodayDate()}
|
||||
helperText="When does the event take place?"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={1}>
|
||||
<Input
|
||||
id="dayOfWeek"
|
||||
name="dayOfWeek"
|
||||
label="Day of Week"
|
||||
value={dayOfWeek}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
readOnly
|
||||
helperText="This field is automatically populated based on the selected date"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className={`text-sm font-semibold ${getThemeClasses("text-default")}`}>
|
||||
Facilitator/Speaker Time Frames
|
||||
</p>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center cursor-pointer" htmlFor="facilitator-am">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="facilitator-am"
|
||||
name="facilitatorAM"
|
||||
checked={formData.facilitatorAM || false}
|
||||
onChange={handleFacilitatorAMChange}
|
||||
className={`h-4 w-4 ${getThemeClasses("checkbox-focus")} ${getThemeClasses("border-secondary")} rounded focus:ring-2`}
|
||||
/>
|
||||
<span className={`ml-2 text-sm ${getThemeClasses("text-default")}`}>AM</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer" htmlFor="facilitator-pm">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="facilitator-pm"
|
||||
name="facilitatorPM"
|
||||
checked={formData.facilitatorPM || false}
|
||||
onChange={handleFacilitatorPMChange}
|
||||
className={`h-4 w-4 ${getThemeClasses("checkbox-focus")} ${getThemeClasses("border-secondary")} rounded focus:ring-2`}
|
||||
/>
|
||||
<span className={`ml-2 text-sm ${getThemeClasses("text-default")}`}>PM</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
Select when facilitators/speakers will be needed
|
||||
</p>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Location & Logistics Section
|
||||
export const EventLocationSection = React.memo(function EventLocationSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Memoized handlers for inputs
|
||||
const handleLocationChange = useCallback(
|
||||
(value) => {
|
||||
onChange("location", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAttendeeCapacityChange = useCallback(
|
||||
(value) => {
|
||||
onChange("attendeeCapacity", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePartnersChange = useCallback(
|
||||
(value) => {
|
||||
onChange("partners", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleListingURLChange = useCallback(
|
||||
(value) => {
|
||||
onChange("listingURL", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Location & Logistics" icon={MapPinIcon}>
|
||||
<FormRow columns={1}>
|
||||
<Input
|
||||
id="location"
|
||||
name="location"
|
||||
label="Location"
|
||||
value={formData.location}
|
||||
onChange={handleLocationChange}
|
||||
error={errors.location}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
id="attendeeCapacity"
|
||||
name="attendeeCapacity"
|
||||
label="Attendee Capacity"
|
||||
type="number"
|
||||
value={formData.attendeeCapacity}
|
||||
onChange={handleAttendeeCapacityChange}
|
||||
error={errors.attendeeCapacity}
|
||||
/>
|
||||
<Input
|
||||
id="partners"
|
||||
name="partners"
|
||||
label="Partners"
|
||||
value={formData.partners}
|
||||
onChange={handlePartnersChange}
|
||||
error={errors.partners}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={1}>
|
||||
<Input
|
||||
id="listingURL"
|
||||
name="listingURL"
|
||||
label="Event Listing URL"
|
||||
type="url"
|
||||
value={formData.listingURL}
|
||||
onChange={handleListingURLChange}
|
||||
error={errors.listingURL}
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Contact Information Section
|
||||
export const EventContactSection = React.memo(function EventContactSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Memoized handlers for inputs
|
||||
const handleContactNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("contactName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleContactEmailChange = useCallback(
|
||||
(value) => {
|
||||
onChange("contactEmail", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleContactPhoneChange = useCallback(
|
||||
(value) => {
|
||||
onChange("contactPhone", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Contact Information" icon={EnvelopeIcon}>
|
||||
<FormRow columns={1}>
|
||||
<Input
|
||||
id="contactName"
|
||||
name="contactName"
|
||||
label="Contact Name"
|
||||
value={formData.contactName}
|
||||
onChange={handleContactNameChange}
|
||||
error={errors.contactName}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
name="contactEmail"
|
||||
label="Contact Email"
|
||||
type="email"
|
||||
value={formData.contactEmail}
|
||||
onChange={handleContactEmailChange}
|
||||
error={errors.contactEmail}
|
||||
/>
|
||||
<Input
|
||||
id="contactPhone"
|
||||
name="contactPhone"
|
||||
label="Contact Phone"
|
||||
type="tel"
|
||||
value={formData.contactPhone}
|
||||
onChange={handleContactPhoneChange}
|
||||
error={errors.contactPhone}
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Settings & Status Section
|
||||
export const EventSettingsSection = React.memo(function EventSettingsSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Memoized handlers for selects
|
||||
const handleStatusChange = useCallback(
|
||||
(value) => {
|
||||
onChange("status", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleSHSMChange = useCallback(
|
||||
(value) => {
|
||||
onChange("isSHSM", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Settings & Status" icon={CogIcon}>
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
id="status"
|
||||
name="status"
|
||||
label="Event Status"
|
||||
value={formData.status}
|
||||
onChange={handleStatusChange}
|
||||
options={EVENT_STATUS_OPTIONS}
|
||||
error={errors.status}
|
||||
/>
|
||||
<Select
|
||||
id="isSHSM"
|
||||
name="isSHSM"
|
||||
label="SHSM Eligible"
|
||||
value={formData.isSHSM}
|
||||
onChange={handleSHSMChange}
|
||||
options={SHSM_OPTIONS}
|
||||
error={errors.isSHSM}
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Specializations & Certifications Section
|
||||
export const EventSpecializationCertificationSection = React.memo(function EventSpecializationCertificationSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Handler for SpecializationCertificationPicker
|
||||
const handleSpecializationCertificationChange = useCallback(
|
||||
(value) => {
|
||||
// Update all three fields when the picker changes
|
||||
onChange("certificationIds", value.certificationIds);
|
||||
onChange("specializationIds", value.specializationIds);
|
||||
onChange("specializationCertificationItems", value._items || []);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Build value object for SpecializationCertificationPicker
|
||||
const pickerValue = useMemo(() => ({
|
||||
certificationIds: formData.certificationIds || [],
|
||||
specializationIds: formData.specializationIds || [],
|
||||
_items: formData.specializationCertificationItems || [],
|
||||
}), [formData.certificationIds, formData.specializationIds, formData.specializationCertificationItems]);
|
||||
|
||||
const onUnauthorized = useCallback(() => {
|
||||
// Navigate to login or show error
|
||||
window.location.href = "/login?unauthorized=true";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormSection title="Specializations & Certifications" icon={AcademicCapIcon}>
|
||||
<SpecializationCertificationPicker
|
||||
id="specializationCertification"
|
||||
value={pickerValue}
|
||||
onChange={handleSpecializationCertificationChange}
|
||||
error={errors.certifications}
|
||||
label="Event Specializations & Certifications"
|
||||
helperText="Select specializations and their associated certifications for this event"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/OrganizationFormSections.jsx
|
||||
|
||||
import React, { useCallback, useMemo, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
UserGroupIcon,
|
||||
MapPinIcon,
|
||||
DocumentTextIcon,
|
||||
UserIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
TagIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { FormSection, FormRow, Input, Select, Checkbox, MultiSelect } from "../../index";
|
||||
import { useUIXTheme } from "../../themes/useUIXTheme.jsx";
|
||||
import { useTagManager } from "../../../../services/Services";
|
||||
|
||||
// Organization type options - frozen constant
|
||||
const ORGANIZATION_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: "educational", label: "Educational" },
|
||||
{ value: "corporate", label: "Corporate" },
|
||||
{ value: "non-profit", label: "Non-Profit" },
|
||||
{ value: "government", label: "Government" },
|
||||
]);
|
||||
|
||||
// Phone type options - frozen constant
|
||||
const PHONE_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: 0, label: "Please select" },
|
||||
{ value: 1, label: "Mobile" },
|
||||
{ value: 2, label: "Work" },
|
||||
{ value: 3, label: "Home" },
|
||||
]);
|
||||
|
||||
// Default contact template - frozen constant
|
||||
const DEFAULT_CONTACT = Object.freeze({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
title: "",
|
||||
email: "",
|
||||
isOkToEmail: true,
|
||||
phone: "",
|
||||
phoneType: 0,
|
||||
phoneExtension: "",
|
||||
otherPhone: "",
|
||||
otherPhoneType: 0,
|
||||
otherPhoneExtension: "",
|
||||
});
|
||||
|
||||
// Maximum contacts allowed
|
||||
const MAX_CONTACTS = 10;
|
||||
|
||||
// Generate unique ID for contacts
|
||||
const generateContactId = () =>
|
||||
`contact-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Basic Information Section
|
||||
export const OrganizationBasicInfoSection = React.memo(
|
||||
function OrganizationBasicInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers
|
||||
const handleOrganizationNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("organizationName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleOrganizationShortNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("organizationShortName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleOrganizationTypeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("organizationType", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleWebsiteChange = useCallback(
|
||||
(value) => {
|
||||
onChange("website", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e) => {
|
||||
onChange("description", e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Basic Information" icon={BuildingOffice2Icon}>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Organization Name"
|
||||
value={formData.organizationName}
|
||||
onChange={handleOrganizationNameChange}
|
||||
error={errors.organizationName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Short Name"
|
||||
value={formData.organizationShortName}
|
||||
onChange={handleOrganizationShortNameChange}
|
||||
error={errors.organizationShortName}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
label="Organization Type"
|
||||
value={formData.organizationType}
|
||||
onChange={handleOrganizationTypeChange}
|
||||
options={ORGANIZATION_TYPE_OPTIONS}
|
||||
error={errors.organizationType}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={handleWebsiteChange}
|
||||
error={errors.website}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium ${getThemeClasses("text-default")} mb-2`}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={handleDescriptionChange}
|
||||
rows={3}
|
||||
className={`block w-full px-3 py-2 border ${getThemeClasses("input-border")} rounded-lg ${getThemeClasses("focus-ring")} ${getThemeClasses("focus-border")}`}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Contact Information Section
|
||||
export const OrganizationContactInfoSection = React.memo(
|
||||
function OrganizationContactInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Initialize contacts with unique IDs
|
||||
const contacts = useMemo(() => {
|
||||
const existingContacts = formData.contacts || [];
|
||||
if (existingContacts.length === 0) {
|
||||
return [
|
||||
{
|
||||
...DEFAULT_CONTACT,
|
||||
id: generateContactId(),
|
||||
},
|
||||
];
|
||||
}
|
||||
// Ensure all contacts have IDs
|
||||
return existingContacts.map((contact) => ({
|
||||
...contact,
|
||||
id: contact.id || generateContactId(),
|
||||
}));
|
||||
}, [formData.contacts]);
|
||||
|
||||
// Memoized handler for adding contacts
|
||||
const addContact = useCallback(() => {
|
||||
if (contacts.length < MAX_CONTACTS) {
|
||||
const newContacts = [
|
||||
...contacts,
|
||||
{
|
||||
...DEFAULT_CONTACT,
|
||||
id: generateContactId(),
|
||||
},
|
||||
];
|
||||
onChange("contacts", newContacts);
|
||||
}
|
||||
}, [contacts, onChange]);
|
||||
|
||||
// Memoized handler for removing contacts
|
||||
const removeContact = useCallback(
|
||||
(index) => {
|
||||
if (contacts.length > 1) {
|
||||
const newContacts = contacts.filter((_, i) => i !== index);
|
||||
onChange("contacts", newContacts);
|
||||
}
|
||||
},
|
||||
[contacts, onChange],
|
||||
);
|
||||
|
||||
// Memoized handler for updating contact fields
|
||||
const updateContact = useCallback(
|
||||
(index, field, value) => {
|
||||
const newContacts = [...contacts];
|
||||
newContacts[index] = {
|
||||
...newContacts[index],
|
||||
[field]: value,
|
||||
};
|
||||
onChange("contacts", newContacts);
|
||||
},
|
||||
[contacts, onChange],
|
||||
);
|
||||
|
||||
// Calculate remaining contacts allowed
|
||||
const remainingContacts = MAX_CONTACTS - contacts.length;
|
||||
const canAddMore = remainingContacts > 0;
|
||||
|
||||
return (
|
||||
<FormSection title="Contact Information" icon={UserGroupIcon}>
|
||||
{contacts.map((contact, index) => (
|
||||
<OrganizationContactCard
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
index={index}
|
||||
errors={errors}
|
||||
canRemove={contacts.length > 1 && index > 0}
|
||||
onUpdate={updateContact}
|
||||
onRemove={() => removeContact(index)}
|
||||
getThemeClasses={getThemeClasses}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Additional Contact Button */}
|
||||
{canAddMore && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addContact}
|
||||
className={`inline-flex items-center px-4 py-2 border ${getThemeClasses("border-accent")} ${getThemeClasses("text-accent")} rounded-lg hover:${getThemeClasses("bg-accent-light")} transition-colors text-sm font-medium`}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Additional Contact
|
||||
</button>
|
||||
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
|
||||
You can add up to {remainingContacts} more contact
|
||||
{remainingContacts !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Separate ContactCard component for better performance
|
||||
const OrganizationContactCard = React.memo(function OrganizationContactCard({
|
||||
contact,
|
||||
index,
|
||||
errors,
|
||||
canRemove,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
getThemeClasses,
|
||||
}) {
|
||||
// Create field-specific handlers
|
||||
const createFieldHandler = useCallback(
|
||||
(field) => {
|
||||
return (value) => onUpdate(index, field, value);
|
||||
},
|
||||
[index, onUpdate],
|
||||
);
|
||||
|
||||
const handlePhoneTypeChange = useCallback(
|
||||
(value) => {
|
||||
onUpdate(index, "phoneType", parseInt(value));
|
||||
},
|
||||
[index, onUpdate],
|
||||
);
|
||||
|
||||
const handleOtherPhoneTypeChange = useCallback(
|
||||
(value) => {
|
||||
onUpdate(index, "otherPhoneType", parseInt(value));
|
||||
},
|
||||
[index, onUpdate],
|
||||
);
|
||||
|
||||
const handleIsOkToEmailChange = useCallback(
|
||||
(checked) => {
|
||||
onUpdate(index, "isOkToEmail", checked);
|
||||
},
|
||||
[index, onUpdate],
|
||||
);
|
||||
|
||||
const contactNumber = index + 1;
|
||||
const showContactNumber = index > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-6 pb-6 border-b last:border-b-0 ${getThemeClasses("border-default")}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3
|
||||
className={`text-lg font-semibold ${getThemeClasses("text-primary")} flex items-center`}
|
||||
>
|
||||
<UserIcon
|
||||
className={`w-5 h-5 mr-2 ${getThemeClasses("text-accent")}`}
|
||||
/>
|
||||
Contact Person{showContactNumber ? ` #${contactNumber}` : ""}
|
||||
</h3>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={`${getThemeClasses("text-error")} hover:${getThemeClasses("text-error-hover")} flex items-center text-sm font-medium`}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 mr-1" />
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={contact.firstName}
|
||||
onChange={createFieldHandler("firstName")}
|
||||
error={errors[`contact_${index}_firstName`]}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={contact.lastName}
|
||||
onChange={createFieldHandler("lastName")}
|
||||
error={errors[`contact_${index}_lastName`]}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<Input
|
||||
label="Title"
|
||||
value={contact.title}
|
||||
onChange={createFieldHandler("title")}
|
||||
error={errors[`contact_${index}_title`]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={contact.email}
|
||||
onChange={createFieldHandler("email")}
|
||||
error={errors[`contact_${index}_email`]}
|
||||
required
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="I agree to receive electronic email"
|
||||
checked={contact.isOkToEmail}
|
||||
onChange={handleIsOkToEmailChange}
|
||||
/>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
value={contact.phone}
|
||||
onChange={createFieldHandler("phone")}
|
||||
error={errors[`contact_${index}_phone`]}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Phone Type"
|
||||
value={contact.phoneType}
|
||||
onChange={handlePhoneTypeChange}
|
||||
options={PHONE_TYPE_OPTIONS}
|
||||
error={errors[`contact_${index}_phoneType`]}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{contact.phoneType === 2 && (
|
||||
<Input
|
||||
label="Phone Extension"
|
||||
value={contact.phoneExtension}
|
||||
onChange={createFieldHandler("phoneExtension")}
|
||||
error={errors[`contact_${index}_phoneExtension`]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Other Phone Number (Optional)"
|
||||
type="tel"
|
||||
value={contact.otherPhone}
|
||||
onChange={createFieldHandler("otherPhone")}
|
||||
error={errors[`contact_${index}_otherPhone`]}
|
||||
/>
|
||||
<Select
|
||||
label="Other Phone Type"
|
||||
value={contact.otherPhoneType}
|
||||
onChange={handleOtherPhoneTypeChange}
|
||||
options={PHONE_TYPE_OPTIONS}
|
||||
error={errors[`contact_${index}_otherPhoneType`]}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{contact.otherPhoneType === 2 && (
|
||||
<Input
|
||||
label="Other Phone Extension (Optional)"
|
||||
value={contact.otherPhoneExtension}
|
||||
onChange={createFieldHandler("otherPhoneExtension")}
|
||||
error={errors[`contact_${index}_otherPhoneExtension`]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Address Information Section
|
||||
export const OrganizationAddressSection = React.memo(
|
||||
function OrganizationAddressSection({ formData, errors, onChange }) {
|
||||
// Create memoized handlers for each field
|
||||
const handleCountryChange = useCallback(
|
||||
(value) => {
|
||||
onChange("country", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleRegionChange = useCallback(
|
||||
(value) => {
|
||||
onChange("region", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleCityChange = useCallback(
|
||||
(value) => {
|
||||
onChange("city", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAddressLine1Change = useCallback(
|
||||
(value) => {
|
||||
onChange("addressLine1", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAddressLine2Change = useCallback(
|
||||
(value) => {
|
||||
onChange("addressLine2", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePostalCodeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("postalCode", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Address Information" icon={MapPinIcon}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Country"
|
||||
value={formData.country}
|
||||
onChange={handleCountryChange}
|
||||
error={errors.country}
|
||||
/>
|
||||
<Input
|
||||
label="Province/State"
|
||||
value={formData.region}
|
||||
onChange={handleRegionChange}
|
||||
error={errors.region}
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
value={formData.city}
|
||||
onChange={handleCityChange}
|
||||
error={errors.city}
|
||||
/>
|
||||
<Input
|
||||
label="Address Line 1"
|
||||
value={formData.addressLine1}
|
||||
onChange={handleAddressLine1Change}
|
||||
error={errors.addressLine1}
|
||||
/>
|
||||
<Input
|
||||
label="Address Line 2 (Optional)"
|
||||
value={formData.addressLine2}
|
||||
onChange={handleAddressLine2Change}
|
||||
error={errors.addressLine2}
|
||||
/>
|
||||
<Input
|
||||
label="Postal Code"
|
||||
value={formData.postalCode}
|
||||
onChange={handlePostalCodeChange}
|
||||
error={errors.postalCode}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Business Information Section
|
||||
export const OrganizationBusinessInfoSection = React.memo(
|
||||
function OrganizationBusinessInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers for all fields
|
||||
const handleTaxIdChange = useCallback(
|
||||
(value) => {
|
||||
onChange("taxId", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleRegistrationNumberChange = useCallback(
|
||||
(value) => {
|
||||
onChange("registrationNumber", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAllowOnlineBookingsChange = useCallback(
|
||||
(checked) => {
|
||||
onChange("allowOnlineBookings", checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleRequireApprovalForBookingsChange = useCallback(
|
||||
(checked) => {
|
||||
onChange("requireApprovalForBookings", checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Business Information" icon={DocumentTextIcon}>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Tax ID / Business Number"
|
||||
value={formData.taxId}
|
||||
onChange={handleTaxIdChange}
|
||||
error={errors.taxId}
|
||||
placeholder="Organization's tax identification number"
|
||||
/>
|
||||
<Input
|
||||
label="Registration Number"
|
||||
value={formData.registrationNumber}
|
||||
onChange={handleRegistrationNumberChange}
|
||||
error={errors.registrationNumber}
|
||||
placeholder="Official registration or license number"
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<h4 className={`text-sm font-semibold ${getThemeClasses("text-primary")}`}>
|
||||
Booking Settings
|
||||
</h4>
|
||||
|
||||
<Checkbox
|
||||
label="Allow Online Bookings"
|
||||
checked={formData.allowOnlineBookings}
|
||||
onChange={handleAllowOnlineBookingsChange}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Require Approval for Bookings"
|
||||
checked={formData.requireApprovalForBookings}
|
||||
onChange={handleRequireApprovalForBookingsChange}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Additional Information Section
|
||||
export const OrganizationAdditionalInfoSection = React.memo(
|
||||
function OrganizationAdditionalInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
const tagManager = useTagManager();
|
||||
const [tagOptions, setTagOptions] = useState([]);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
// Load tag options with cleanup
|
||||
useEffect(() => {
|
||||
// Create new abort controller for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const options = await tagManager.getTagSelectOptions(() => {}, {
|
||||
signal: abortControllerRef.current?.signal,
|
||||
});
|
||||
|
||||
const formattedOptions = options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}));
|
||||
setTagOptions(formattedOptions);
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("OrganizationAdditionalInfoSection: Error loading tags:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadTags();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Abort any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [tagManager]);
|
||||
|
||||
return (
|
||||
<FormSection title="Additional Information" icon={TagIcon}>
|
||||
<div className="space-y-6">
|
||||
<MultiSelect
|
||||
label="Tags (Optional)"
|
||||
value={formData.tags || []}
|
||||
onChange={(value) => onChange("tags", value)}
|
||||
options={tagOptions}
|
||||
placeholder="Select tags..."
|
||||
error={errors.tags}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/OrganizationUpdatePageExample.jsx
|
||||
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChartBarIcon,
|
||||
BuildingOffice2Icon,
|
||||
InformationCircleIcon,
|
||||
PencilSquareIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useOrganizationManager } from "../../../../services/Services";
|
||||
import { EntityUpdatePage } from "../../index";
|
||||
import {
|
||||
OrganizationBasicInfoSection,
|
||||
OrganizationContactInfoSection,
|
||||
OrganizationAddressSection,
|
||||
OrganizationBusinessInfoSection,
|
||||
} from "./OrganizationFormSections";
|
||||
|
||||
// Static configuration constants moved outside component
|
||||
const ENTITY_NAME = "Organization";
|
||||
const ENTITY_TYPE = "organization";
|
||||
const ID_PARAM = "organizationId";
|
||||
|
||||
// Static initial form data template
|
||||
const INITIAL_FORM_DATA = Object.freeze({
|
||||
organizationName: "",
|
||||
organizationShortName: "",
|
||||
organizationType: "",
|
||||
name: "",
|
||||
description: "",
|
||||
taxId: "",
|
||||
website: "",
|
||||
country: "",
|
||||
region: "",
|
||||
city: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
postalCode: "",
|
||||
hasShippingAddress: false,
|
||||
shippingName: "",
|
||||
shippingPhone: "",
|
||||
shippingCountry: "",
|
||||
shippingRegion: "",
|
||||
shippingCity: "",
|
||||
shippingAddressLine1: "",
|
||||
shippingAddressLine2: "",
|
||||
shippingPostalCode: "",
|
||||
foundedDate: "",
|
||||
annualRevenue: "",
|
||||
numberOfEmployees: "",
|
||||
fiscalYearEnd: "",
|
||||
creditRating: "",
|
||||
paymentTerms: "",
|
||||
additionalComment: "",
|
||||
tags: [],
|
||||
contacts: [
|
||||
{
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
title: "",
|
||||
email: "",
|
||||
isOkToEmail: true,
|
||||
phone: "",
|
||||
phoneType: 0,
|
||||
phoneExtension: "",
|
||||
otherPhone: "",
|
||||
otherPhoneType: 0,
|
||||
otherPhoneExtension: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Default contact template
|
||||
const DEFAULT_CONTACT = Object.freeze({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
title: "",
|
||||
email: "",
|
||||
isOkToEmail: true,
|
||||
phone: "",
|
||||
phoneType: 0,
|
||||
phoneExtension: "",
|
||||
otherPhone: "",
|
||||
otherPhoneType: 0,
|
||||
otherPhoneExtension: "",
|
||||
});
|
||||
|
||||
// Static form sections array
|
||||
const FORM_SECTIONS = Object.freeze([
|
||||
OrganizationBasicInfoSection,
|
||||
OrganizationContactInfoSection,
|
||||
OrganizationAddressSection,
|
||||
OrganizationBusinessInfoSection,
|
||||
]);
|
||||
|
||||
// Static breadcrumb items
|
||||
const BREADCRUMB_ITEMS = Object.freeze([
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
hideOnMobile: false,
|
||||
mobileLabel: "Dash",
|
||||
},
|
||||
{
|
||||
label: "Organizations",
|
||||
to: "/admin/organizations",
|
||||
icon: BuildingOffice2Icon,
|
||||
},
|
||||
{
|
||||
label: "Detail",
|
||||
icon: InformationCircleIcon,
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
icon: PencilSquareIcon,
|
||||
isActive: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Static tab items
|
||||
const TAB_ITEMS = Object.freeze([
|
||||
{
|
||||
label: "Summary",
|
||||
},
|
||||
{
|
||||
label: "Full Details",
|
||||
},
|
||||
{
|
||||
label: "Events",
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
label: "Comments",
|
||||
},
|
||||
{
|
||||
label: "Attachments",
|
||||
},
|
||||
{
|
||||
label: "More",
|
||||
icon: EllipsisHorizontalIcon,
|
||||
},
|
||||
]);
|
||||
|
||||
// Validation function moved outside component
|
||||
const validateForm = (formData) => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.organizationName?.trim()) {
|
||||
newErrors.organizationName = "Organization name is required";
|
||||
}
|
||||
if (!formData.organizationType?.trim()) {
|
||||
newErrors.organizationType = "Organization type is required";
|
||||
}
|
||||
|
||||
// Validate contacts
|
||||
const contacts = formData.contacts || [];
|
||||
contacts.forEach((contact, index) => {
|
||||
if (!contact.firstName?.trim()) {
|
||||
newErrors[`contact_${index}_firstName`] = "First name is required";
|
||||
}
|
||||
if (!contact.lastName?.trim()) {
|
||||
newErrors[`contact_${index}_lastName`] = "Last name is required";
|
||||
}
|
||||
if (!contact.email?.trim()) {
|
||||
newErrors[`contact_${index}_email`] = "Email is required";
|
||||
}
|
||||
if (!contact.phone?.trim()) {
|
||||
newErrors[`contact_${index}_phone`] = "Phone is required";
|
||||
}
|
||||
});
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
// Format response data function moved outside component
|
||||
const formatDataFromResponse = (response) => {
|
||||
return {
|
||||
organizationName: response.organizationName || "",
|
||||
organizationShortName: response.organizationShortName || "",
|
||||
organizationType: response.organizationType || "",
|
||||
name: response.name || "",
|
||||
description: response.description || "",
|
||||
taxId: response.taxId || "",
|
||||
website: response.website || "",
|
||||
country: response.country || "",
|
||||
region: response.region || "",
|
||||
city: response.city || "",
|
||||
addressLine1: response.addressLine1 || "",
|
||||
addressLine2: response.addressLine2 || "",
|
||||
postalCode: response.postalCode || "",
|
||||
hasShippingAddress: response.hasShippingAddress || false,
|
||||
shippingName: response.shippingName || "",
|
||||
shippingPhone: response.shippingPhone || "",
|
||||
shippingCountry: response.shippingCountry || "",
|
||||
shippingRegion: response.shippingRegion || "",
|
||||
shippingCity: response.shippingCity || "",
|
||||
shippingAddressLine1: response.shippingAddressLine1 || "",
|
||||
shippingAddressLine2: response.shippingAddressLine2 || "",
|
||||
shippingPostalCode: response.shippingPostalCode || "",
|
||||
foundedDate: response.foundedDate || "",
|
||||
annualRevenue: response.annualRevenue || "",
|
||||
numberOfEmployees: response.numberOfEmployees || "",
|
||||
fiscalYearEnd: response.fiscalYearEnd || "",
|
||||
creditRating: response.creditRating || "",
|
||||
paymentTerms: response.paymentTerms || "",
|
||||
additionalComment: response.additionalComment || "",
|
||||
tags: response.tags || [],
|
||||
contacts:
|
||||
response.contacts &&
|
||||
Array.isArray(response.contacts) &&
|
||||
response.contacts.length > 0
|
||||
? response.contacts.map((contact) => ({
|
||||
...DEFAULT_CONTACT,
|
||||
...contact,
|
||||
}))
|
||||
: [{ ...DEFAULT_CONTACT }],
|
||||
};
|
||||
};
|
||||
|
||||
// Format submit data function moved outside component
|
||||
const formatDataForSubmit = (formData, entityId) => {
|
||||
return {
|
||||
...formData,
|
||||
id: entityId,
|
||||
contacts: formData.contacts || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Example usage of EntityUpdatePage for Organization
|
||||
* This shows how the reusable component can be configured for Organization entities
|
||||
*/
|
||||
const OrganizationUpdatePageExample = React.memo(
|
||||
function OrganizationUpdatePageExample() {
|
||||
const organizationManager = useOrganizationManager();
|
||||
|
||||
// Memoize the manager object with its methods
|
||||
const manager = useMemo(
|
||||
() => ({
|
||||
getDetail: (id, onUnauthorized, options) =>
|
||||
organizationManager.getOrganizationDetail(
|
||||
id,
|
||||
onUnauthorized,
|
||||
options,
|
||||
),
|
||||
update: (id, data, onUnauthorized) =>
|
||||
organizationManager.updateOrganization(id, data, onUnauthorized),
|
||||
}),
|
||||
[organizationManager],
|
||||
);
|
||||
|
||||
// Memoize the entire configuration object
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
// Basic entity information
|
||||
entityName: ENTITY_NAME,
|
||||
entityType: ENTITY_TYPE,
|
||||
idParam: ID_PARAM,
|
||||
|
||||
// Icons
|
||||
icon: BuildingOffice2Icon,
|
||||
dashboardIcon: ChartBarIcon,
|
||||
detailIcon: InformationCircleIcon,
|
||||
updateIcon: PencilSquareIcon,
|
||||
|
||||
// Manager with CRUD operations
|
||||
manager,
|
||||
|
||||
// Initial form data structure - create new object from frozen template
|
||||
initialFormData: { ...INITIAL_FORM_DATA },
|
||||
|
||||
// Form sections to render
|
||||
formSections: FORM_SECTIONS,
|
||||
|
||||
// Validation, formatting functions
|
||||
validateForm,
|
||||
formatDataFromResponse,
|
||||
formatDataForSubmit,
|
||||
|
||||
// Custom breadcrumb items
|
||||
breadcrumbItems: BREADCRUMB_ITEMS,
|
||||
|
||||
// Custom tab items
|
||||
tabItems: TAB_ITEMS,
|
||||
}),
|
||||
[manager],
|
||||
);
|
||||
|
||||
return <EntityUpdatePage config={config} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default OrganizationUpdatePageExample;
|
||||
|
|
@ -0,0 +1,688 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/StaffFormSections.jsx
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
UserIcon,
|
||||
MapPinIcon,
|
||||
BriefcaseIcon,
|
||||
ExclamationCircleIcon,
|
||||
ChartPieIcon,
|
||||
ComputerDesktopIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
FormSection,
|
||||
FormRow,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
DateInput,
|
||||
} from "../../index";
|
||||
import {
|
||||
TagsMultiSelect,
|
||||
VehicleTypesMultiSelect,
|
||||
HowHearAboutUsSelect,
|
||||
} from "../../../business/selects";
|
||||
import {
|
||||
STAFF_TYPE_EXECUTIVE,
|
||||
STAFF_TYPE_MANAGEMENT,
|
||||
STAFF_TYPE_FRONTLINE,
|
||||
STAFF_PHONE_TYPE_OF_OPTIONS,
|
||||
STAFF_GENDER_OTHER,
|
||||
} from "../../../../constants/Staff";
|
||||
import {
|
||||
GENDER_OPTIONS,
|
||||
IDENTIFY_AS_OPTIONS,
|
||||
} from "../../../../constants/FieldOptions";
|
||||
import { useUIXTheme } from "../../themes/useUIXTheme.jsx";
|
||||
|
||||
// Static constants - frozen for performance
|
||||
const STAFF_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: STAFF_TYPE_EXECUTIVE, label: "Executive" },
|
||||
{ value: STAFF_TYPE_MANAGEMENT, label: "Management" },
|
||||
{ value: STAFF_TYPE_FRONTLINE, label: "Frontline" },
|
||||
]);
|
||||
|
||||
const LANGUAGE_OPTIONS = Object.freeze([
|
||||
{ value: "English", label: "English" },
|
||||
{ value: "French", label: "French" },
|
||||
]);
|
||||
|
||||
// Get today's date for birth date max value
|
||||
const TODAY_ISO = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Basic Information Section
|
||||
export const StaffBasicInfoSection = React.memo(function StaffBasicInfoSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
// Memoized handlers
|
||||
const handleTypeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("type", parseInt(value));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleEmailChange = useCallback(
|
||||
(value) => {
|
||||
onChange("email", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleFirstNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("firstName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleLastNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("lastName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleIsOkToEmailChange = useCallback(
|
||||
(checked) => {
|
||||
onChange("isOkToEmail", checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePhoneChange = useCallback(
|
||||
(value) => {
|
||||
onChange("phone", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePhoneTypeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("phoneType", parseInt(value));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleIsOkToTextChange = useCallback(
|
||||
(checked) => {
|
||||
onChange("isOkToText", checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Basic Information" icon={UserIcon}>
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
label="Type"
|
||||
value={formData.type}
|
||||
onChange={handleTypeChange}
|
||||
options={STAFF_TYPE_OPTIONS}
|
||||
error={errors.type}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleEmailChange}
|
||||
error={errors.email}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={formData.firstName}
|
||||
onChange={handleFirstNameChange}
|
||||
error={errors.firstName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={formData.lastName}
|
||||
onChange={handleLastNameChange}
|
||||
error={errors.lastName}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<Checkbox
|
||||
label="I agree to receive electronic email"
|
||||
checked={formData.isOkToEmail}
|
||||
onChange={handleIsOkToEmailChange}
|
||||
/>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handlePhoneChange}
|
||||
error={errors.phone}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Phone Type"
|
||||
value={formData.phoneType}
|
||||
onChange={handlePhoneTypeChange}
|
||||
options={STAFF_PHONE_TYPE_OF_OPTIONS}
|
||||
error={errors.phoneType}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<Checkbox
|
||||
label="I agree to receive texts to my phone"
|
||||
checked={formData.isOkToText}
|
||||
onChange={handleIsOkToTextChange}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Address Information Section
|
||||
export const StaffAddressSection = React.memo(function StaffAddressSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers for all address fields
|
||||
const handleHasShippingAddressChange = useCallback(
|
||||
(checked) => {
|
||||
onChange("hasShippingAddress", checked);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleCountryChange = useCallback(
|
||||
(value) => {
|
||||
onChange("country", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleRegionChange = useCallback(
|
||||
(value) => {
|
||||
onChange("region", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleCityChange = useCallback(
|
||||
(value) => {
|
||||
onChange("city", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAddressLine1Change = useCallback(
|
||||
(value) => {
|
||||
onChange("addressLine1", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleAddressLine2Change = useCallback(
|
||||
(value) => {
|
||||
onChange("addressLine2", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePostalCodeChange = useCallback(
|
||||
(value) => {
|
||||
onChange("postalCode", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Address Information" icon={MapPinIcon}>
|
||||
<Checkbox
|
||||
label="Has mailing address different than home address"
|
||||
checked={formData.hasShippingAddress}
|
||||
onChange={handleHasShippingAddressChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`grid ${formData.hasShippingAddress ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"} gap-6`}
|
||||
>
|
||||
{/* Home Address */}
|
||||
<div>
|
||||
{formData.hasShippingAddress && (
|
||||
<h4
|
||||
className={`text-base font-medium ${getThemeClasses("text-primary")} mb-4`}
|
||||
>
|
||||
Home Address
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Country"
|
||||
value={formData.country}
|
||||
onChange={handleCountryChange}
|
||||
error={errors.country}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Province/State"
|
||||
value={formData.region}
|
||||
onChange={handleRegionChange}
|
||||
error={errors.region}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="City"
|
||||
value={formData.city}
|
||||
onChange={handleCityChange}
|
||||
error={errors.city}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Address Line 1"
|
||||
value={formData.addressLine1}
|
||||
onChange={handleAddressLine1Change}
|
||||
error={errors.addressLine1}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Address Line 2 (Optional)"
|
||||
value={formData.addressLine2}
|
||||
onChange={handleAddressLine2Change}
|
||||
error={errors.addressLine2}
|
||||
/>
|
||||
<Input
|
||||
label="Postal Code"
|
||||
value={formData.postalCode}
|
||||
onChange={handlePostalCodeChange}
|
||||
error={errors.postalCode}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Address */}
|
||||
{formData.hasShippingAddress && (
|
||||
<div>
|
||||
<h4
|
||||
className={`text-base font-medium ${getThemeClasses("text-primary")} mb-4`}
|
||||
>
|
||||
Shipping Address
|
||||
</h4>
|
||||
{/* Shipping address fields would go here */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// Additional Information Section
|
||||
export const StaffAdditionalInfoSection = React.memo(
|
||||
function StaffAdditionalInfoSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
onUnauthorized,
|
||||
}) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers
|
||||
const handleLimitSpecialChange = useCallback(
|
||||
(e) => {
|
||||
onChange("limitSpecial", e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePoliceCheckChange = useCallback(
|
||||
(value) => {
|
||||
onChange("policeCheck", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleVehicleTypesChange = useCallback(
|
||||
(value) => {
|
||||
onChange("vehicleTypes", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Additional Information" icon={BriefcaseIcon}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<textarea
|
||||
value={formData.limitSpecial}
|
||||
onChange={handleLimitSpecialChange}
|
||||
rows={4}
|
||||
className={`block w-full px-3 py-2 border ${getThemeClasses("input-border")} rounded-lg ${getThemeClasses("focus-ring")} ${getThemeClasses("focus-border")}`}
|
||||
maxLength={638}
|
||||
placeholder="Limitation or Special Consideration"
|
||||
/>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-muted")}`}>
|
||||
Max 638 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<DateInput
|
||||
label="Police Check Expiry"
|
||||
value={formData.policeCheck}
|
||||
onChange={handlePoliceCheckChange}
|
||||
error={errors.policeCheck}
|
||||
/>
|
||||
<VehicleTypesMultiSelect
|
||||
value={formData.vehicleTypes}
|
||||
onChange={handleVehicleTypesChange}
|
||||
error={errors.vehicleTypes}
|
||||
required={false}
|
||||
label="Vehicle Types (Optional)"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Emergency Contact Section
|
||||
export const StaffEmergencyContactSection = React.memo(
|
||||
function StaffEmergencyContactSection({ formData, errors, onChange }) {
|
||||
// Memoized handlers
|
||||
const handleContactNameChange = useCallback(
|
||||
(value) => {
|
||||
onChange("emergencyContactName", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleContactRelationshipChange = useCallback(
|
||||
(value) => {
|
||||
onChange("emergencyContactRelationship", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleContactTelephoneChange = useCallback(
|
||||
(value) => {
|
||||
onChange("emergencyContactTelephone", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleContactAltTelephoneChange = useCallback(
|
||||
(value) => {
|
||||
onChange("emergencyContactAlternativeTelephone", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Emergency Contact" icon={ExclamationCircleIcon}>
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Contact Name"
|
||||
value={formData.emergencyContactName}
|
||||
onChange={handleContactNameChange}
|
||||
error={errors.emergencyContactName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Contact Relationship"
|
||||
value={formData.emergencyContactRelationship}
|
||||
onChange={handleContactRelationshipChange}
|
||||
error={errors.emergencyContactRelationship}
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Input
|
||||
label="Contact Telephone"
|
||||
type="tel"
|
||||
value={formData.emergencyContactTelephone}
|
||||
onChange={handleContactTelephoneChange}
|
||||
error={errors.emergencyContactTelephone}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Contact Alternative Telephone (Optional)"
|
||||
type="tel"
|
||||
value={formData.emergencyContactAlternativeTelephone}
|
||||
onChange={handleContactAltTelephoneChange}
|
||||
error={errors.emergencyContactAlternativeTelephone}
|
||||
/>
|
||||
</FormRow>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Metrics Section
|
||||
export const StaffMetricsSection = React.memo(function StaffMetricsSection({
|
||||
formData,
|
||||
errors,
|
||||
onChange,
|
||||
onUnauthorized,
|
||||
}) {
|
||||
// Memoized handlers
|
||||
const handleHowHearChange = useCallback(
|
||||
(value) => {
|
||||
onChange("howDidYouHearAboutUsID", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleHowHearOtherDetected = useCallback(
|
||||
(isOther) => {
|
||||
onChange("isHowDidYouHearAboutUsOther", isOther);
|
||||
if (!isOther) {
|
||||
onChange("howDidYouHearAboutUsOther", "");
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleTagsChange = useCallback(
|
||||
(value) => {
|
||||
onChange("tags", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleHowHearOtherChange = useCallback(
|
||||
(value) => {
|
||||
onChange("howDidYouHearAboutUsOther", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleGenderChange = useCallback(
|
||||
(value) => {
|
||||
onChange("gender", parseInt(value));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleBirthDateChange = useCallback(
|
||||
(value) => {
|
||||
onChange("birthDate", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleJoinDateChange = useCallback(
|
||||
(value) => {
|
||||
onChange("joinDate", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection title="Metrics" icon={ChartPieIcon}>
|
||||
<TagsMultiSelect
|
||||
value={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
error={errors.tags}
|
||||
required={false}
|
||||
label="Tags (Optional)"
|
||||
helperText=""
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
|
||||
<HowHearAboutUsSelect
|
||||
value={formData.howDidYouHearAboutUsID}
|
||||
onChange={handleHowHearChange}
|
||||
onOtherDetected={handleHowHearOtherDetected}
|
||||
error={errors.howDidYouHearAboutUsID}
|
||||
required={true}
|
||||
helperText="Tell us how this person discovered our organization"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
|
||||
{formData.isHowDidYouHearAboutUsOther && (
|
||||
<Input
|
||||
label="How did you hear about us? (Other)"
|
||||
value={formData.howDidYouHearAboutUsOther}
|
||||
onChange={handleHowHearOtherChange}
|
||||
error={errors.howDidYouHearAboutUsOther}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormRow columns={2}>
|
||||
<Select
|
||||
label="Gender"
|
||||
value={formData.gender}
|
||||
onChange={handleGenderChange}
|
||||
options={GENDER_OPTIONS}
|
||||
error={errors.gender}
|
||||
/>
|
||||
<DateInput
|
||||
label="Birth Date (Optional)"
|
||||
value={formData.birthDate}
|
||||
onChange={handleBirthDateChange}
|
||||
max={TODAY_ISO}
|
||||
error={errors.birthDate}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<DateInput
|
||||
label="Join Date (Optional)"
|
||||
value={formData.joinDate}
|
||||
onChange={handleJoinDateChange}
|
||||
error={errors.joinDate}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
});
|
||||
|
||||
// System Information Section
|
||||
export const StaffSystemInfoSection = React.memo(
|
||||
function StaffSystemInfoSection({ formData, errors, onChange }) {
|
||||
const { getThemeClasses } = useUIXTheme();
|
||||
|
||||
// Memoized handlers
|
||||
const handlePreferredLanguageChange = useCallback(
|
||||
(value) => {
|
||||
onChange("preferredLanguage", value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleIdentifyAsChange = useCallback(
|
||||
(e) => {
|
||||
const id = parseInt(e.target.value);
|
||||
const currentIdentifyAs = formData.identifyAs || [];
|
||||
|
||||
if (currentIdentifyAs.includes(id)) {
|
||||
onChange(
|
||||
"identifyAs",
|
||||
currentIdentifyAs.filter((i) => i !== id),
|
||||
);
|
||||
} else {
|
||||
onChange("identifyAs", [...currentIdentifyAs, id]);
|
||||
}
|
||||
},
|
||||
[formData.identifyAs, onChange],
|
||||
);
|
||||
|
||||
// Memoized set of selected values for faster lookups
|
||||
const selectedIdentifyAs = useMemo(
|
||||
() => new Set(formData.identifyAs || []),
|
||||
[formData.identifyAs],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormSection icon={ComputerDesktopIcon}>
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-xl">
|
||||
<Select
|
||||
label="Preferred Language"
|
||||
value={formData.preferredLanguage}
|
||||
onChange={handlePreferredLanguageChange}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
error={errors.preferredLanguage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Identify As Options */}
|
||||
<div className="max-w-xl">
|
||||
<label
|
||||
className={`block text-base sm:text-lg font-semibold ${getThemeClasses("text-primary")} mb-3`}
|
||||
>
|
||||
Do you identify as belonging to any of the following groups?
|
||||
(Optional)
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{IDENTIFY_AS_OPTIONS.map((opt) => (
|
||||
<IdentifyAsCheckbox
|
||||
key={opt.value}
|
||||
option={opt}
|
||||
isChecked={selectedIdentifyAs.has(opt.value)}
|
||||
onChange={handleIdentifyAsChange}
|
||||
getThemeClasses={getThemeClasses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Separate component for identify-as checkboxes for better performance
|
||||
const IdentifyAsCheckbox = React.memo(function IdentifyAsCheckbox({
|
||||
option,
|
||||
isChecked,
|
||||
onChange,
|
||||
getThemeClasses,
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={option.value}
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
className={`w-5 h-5 rounded ${getThemeClasses("input-border")} ${getThemeClasses("accent-primary")} ${getThemeClasses("focus-ring")}`}
|
||||
/>
|
||||
<span
|
||||
className={`ml-3 text-base sm:text-lg ${getThemeClasses("text-default")}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
// File: src/components/UIX/EntityUpdatePage/examples/StaffUpdatePageExample.jsx
|
||||
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChartBarIcon,
|
||||
UserGroupIcon,
|
||||
InformationCircleIcon,
|
||||
PencilSquareIcon,
|
||||
HomeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useStaffManager } from "../../../../services/Services";
|
||||
import { EntityUpdatePage } from "../../index";
|
||||
import {
|
||||
StaffBasicInfoSection,
|
||||
StaffAddressSection,
|
||||
StaffAdditionalInfoSection,
|
||||
StaffEmergencyContactSection,
|
||||
StaffMetricsSection,
|
||||
StaffSystemInfoSection,
|
||||
} from "./StaffFormSections";
|
||||
import {
|
||||
STAFF_TYPE_FRONTLINE,
|
||||
STAFF_GENDER_OTHER,
|
||||
} from "../../../../constants/Staff";
|
||||
|
||||
// Static configuration constants moved outside component
|
||||
const ENTITY_NAME = "Staff Member";
|
||||
const ENTITY_TYPE = "staff";
|
||||
const ID_PARAM = "aid";
|
||||
|
||||
// Static initial form data template
|
||||
const INITIAL_FORM_DATA = Object.freeze({
|
||||
type: STAFF_TYPE_FRONTLINE,
|
||||
email: "",
|
||||
phone: "",
|
||||
phoneType: 0,
|
||||
phoneExtension: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
otherPhone: "",
|
||||
otherPhoneType: 0,
|
||||
otherPhoneExtension: "",
|
||||
isOkToText: false,
|
||||
isOkToEmail: false,
|
||||
postalCode: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
region: "",
|
||||
country: "Canada",
|
||||
hasShippingAddress: false,
|
||||
shippingName: "",
|
||||
shippingPhone: "",
|
||||
shippingCountry: "Canada",
|
||||
shippingRegion: "",
|
||||
shippingCity: "",
|
||||
shippingAddressLine1: "",
|
||||
shippingAddressLine2: "",
|
||||
shippingPostalCode: "",
|
||||
limitSpecial: "",
|
||||
policeCheck: "",
|
||||
driversLicenseClass: "",
|
||||
vehicleTypes: [],
|
||||
skillSets: [],
|
||||
insuranceRequirements: [],
|
||||
emergencyContactName: "",
|
||||
emergencyContactRelationship: "",
|
||||
emergencyContactTelephone: "",
|
||||
emergencyContactAlternativeTelephone: "",
|
||||
description: "",
|
||||
preferredLanguage: "English",
|
||||
tags: [],
|
||||
howDidYouHearAboutUsID: "",
|
||||
isHowDidYouHearAboutUsOther: false,
|
||||
howDidYouHearAboutUsOther: "",
|
||||
birthDate: "",
|
||||
joinDate: "",
|
||||
gender: 0,
|
||||
genderOther: "",
|
||||
identifyAs: [],
|
||||
});
|
||||
|
||||
// Static form sections array
|
||||
const FORM_SECTIONS = Object.freeze([
|
||||
StaffBasicInfoSection,
|
||||
StaffAddressSection,
|
||||
StaffAdditionalInfoSection,
|
||||
StaffEmergencyContactSection,
|
||||
StaffMetricsSection,
|
||||
StaffSystemInfoSection,
|
||||
]);
|
||||
|
||||
// Static breadcrumb items
|
||||
const BREADCRUMB_ITEMS = Object.freeze([
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
hideOnMobile: false,
|
||||
mobileLabel: "Dash",
|
||||
},
|
||||
{
|
||||
label: "Staff",
|
||||
to: "/admin/staff",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
label: "Detail",
|
||||
icon: InformationCircleIcon,
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
icon: PencilSquareIcon,
|
||||
isActive: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Static tab items
|
||||
const TAB_ITEMS = Object.freeze([
|
||||
{
|
||||
label: "Summary",
|
||||
to: `/admin/staff/{aid}`,
|
||||
},
|
||||
{
|
||||
label: "Full Details",
|
||||
to: `/admin/staff/{aid}/detail`,
|
||||
},
|
||||
{
|
||||
label: "Update",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
label: "Comments",
|
||||
to: `/admin/staff/{aid}/comments`,
|
||||
},
|
||||
{
|
||||
label: "Attachments",
|
||||
to: `/admin/staff/{aid}/attachments`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Helper function for date formatting
|
||||
const formatDateForInput = (dateValue) => {
|
||||
if (!dateValue) return "";
|
||||
try {
|
||||
const date = new Date(dateValue);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toISOString().split("T")[0];
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Validation function moved outside component
|
||||
const validateForm = (formData) => {
|
||||
const newErrors = {};
|
||||
|
||||
// Required text fields validation
|
||||
const requiredFields = [
|
||||
{ field: "firstName", message: "First name is required" },
|
||||
{ field: "lastName", message: "Last name is required" },
|
||||
{ field: "email", message: "Email is required" },
|
||||
{ field: "phone", message: "Phone number is required" },
|
||||
{
|
||||
field: "emergencyContactName",
|
||||
message: "Emergency contact name is required",
|
||||
},
|
||||
{
|
||||
field: "emergencyContactRelationship",
|
||||
message: "Emergency contact relationship is required",
|
||||
},
|
||||
{
|
||||
field: "emergencyContactTelephone",
|
||||
message: "Emergency contact telephone is required",
|
||||
},
|
||||
];
|
||||
|
||||
requiredFields.forEach(({ field, message }) => {
|
||||
if (!formData[field]?.trim()) {
|
||||
newErrors[field] = message;
|
||||
}
|
||||
});
|
||||
|
||||
// Conditional validations
|
||||
if (formData.hasShippingAddress) {
|
||||
if (!formData.shippingName?.trim()) {
|
||||
newErrors.shippingName = "Shipping name is required";
|
||||
}
|
||||
if (!formData.shippingPhone?.trim()) {
|
||||
newErrors.shippingPhone = "Shipping phone is required";
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.gender === STAFF_GENDER_OTHER && !formData.genderOther?.trim()) {
|
||||
newErrors.genderOther = "Please specify other gender";
|
||||
}
|
||||
|
||||
if (
|
||||
formData.isHowDidYouHearAboutUsOther &&
|
||||
!formData.howDidYouHearAboutUsOther?.trim()
|
||||
) {
|
||||
newErrors.howDidYouHearAboutUsOther = "Please specify other option";
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
// Format response data function moved outside component
|
||||
const formatDataFromResponse = (response) => {
|
||||
// Helper function to safely map array fields
|
||||
const mapArrayField = (field, mapFn = (item) => item.id || item) => {
|
||||
return field && Array.isArray(field) ? field.map(mapFn) : [];
|
||||
};
|
||||
|
||||
return {
|
||||
type: response.type || STAFF_TYPE_FRONTLINE,
|
||||
email: response.email || "",
|
||||
phone: response.phone || "",
|
||||
phoneType: response.phoneType || 0,
|
||||
phoneExtension: response.phoneExtension || "",
|
||||
firstName: response.firstName || "",
|
||||
lastName: response.lastName || "",
|
||||
otherPhone: response.otherPhone || "",
|
||||
otherPhoneType: response.otherPhoneType || 0,
|
||||
otherPhoneExtension: response.otherPhoneExtension || "",
|
||||
isOkToText: response.isOkToText || false,
|
||||
isOkToEmail: response.isOkToEmail || false,
|
||||
postalCode: response.postalCode || "",
|
||||
addressLine1: response.addressLine1 || "",
|
||||
addressLine2: response.addressLine2 || "",
|
||||
city: response.city || "",
|
||||
region: response.region || "",
|
||||
country: response.country || "Canada",
|
||||
hasShippingAddress: response.hasShippingAddress || false,
|
||||
shippingName: response.shippingName || "",
|
||||
shippingPhone: response.shippingPhone || "",
|
||||
shippingCountry: response.shippingCountry || "Canada",
|
||||
shippingRegion: response.shippingRegion || "",
|
||||
shippingCity: response.shippingCity || "",
|
||||
shippingAddressLine1: response.shippingAddressLine1 || "",
|
||||
shippingAddressLine2: response.shippingAddressLine2 || "",
|
||||
shippingPostalCode: response.shippingPostalCode || "",
|
||||
limitSpecial: response.limitSpecial || "",
|
||||
policeCheck: formatDateForInput(response.policeCheck),
|
||||
driversLicenseClass: response.driversLicenseClass || "",
|
||||
vehicleTypes: mapArrayField(response.vehicleTypes),
|
||||
skillSets: mapArrayField(response.skillSets),
|
||||
insuranceRequirements: mapArrayField(response.insuranceRequirements),
|
||||
emergencyContactName: response.emergencyContactName || "",
|
||||
emergencyContactRelationship: response.emergencyContactRelationship || "",
|
||||
emergencyContactTelephone: response.emergencyContactTelephone || "",
|
||||
emergencyContactAlternativeTelephone:
|
||||
response.emergencyContactAlternativeTelephone || "",
|
||||
description: response.description || "",
|
||||
preferredLanguage: response.preferredLanguage || "English",
|
||||
tags: mapArrayField(response.tags),
|
||||
howDidYouHearAboutUsID: response.howDidYouHearAboutUsID || "",
|
||||
isHowDidYouHearAboutUsOther: response.isHowDidYouHearAboutUsOther || false,
|
||||
howDidYouHearAboutUsOther: response.howDidYouHearAboutUsOther || "",
|
||||
birthDate: formatDateForInput(response.birthDate),
|
||||
joinDate: formatDateForInput(response.joinDate),
|
||||
gender: response.gender || 0,
|
||||
genderOther: response.genderOther || "",
|
||||
identifyAs: response.identifyAs || [],
|
||||
};
|
||||
};
|
||||
|
||||
// Format submit data function moved outside component
|
||||
const formatDataForSubmit = (formData, entityId) => {
|
||||
// Helper to safely parse integer values
|
||||
const safeParseInt = (value, defaultValue = 0) => {
|
||||
const parsed = parseInt(value);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
};
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
type: safeParseInt(formData.type),
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
phoneType: safeParseInt(formData.phoneType),
|
||||
phoneExtension: formData.phoneExtension,
|
||||
otherPhone: formData.otherPhone,
|
||||
otherPhoneType: safeParseInt(formData.otherPhoneType),
|
||||
otherPhoneExtension: formData.otherPhoneExtension,
|
||||
isOkToText: formData.isOkToText,
|
||||
isOkToEmail: formData.isOkToEmail,
|
||||
postalCode: formData.postalCode,
|
||||
addressLine1: formData.addressLine1,
|
||||
addressLine2: formData.addressLine2,
|
||||
city: formData.city,
|
||||
region: formData.region,
|
||||
country: formData.country,
|
||||
hasShippingAddress: formData.hasShippingAddress,
|
||||
shippingName: formData.shippingName,
|
||||
shippingPhone: formData.shippingPhone,
|
||||
shippingCountry: formData.shippingCountry,
|
||||
shippingRegion: formData.shippingRegion,
|
||||
shippingCity: formData.shippingCity,
|
||||
shippingAddressLine1: formData.shippingAddressLine1,
|
||||
shippingAddressLine2: formData.shippingAddressLine2,
|
||||
shippingPostalCode: formData.shippingPostalCode,
|
||||
limitSpecial: formData.limitSpecial,
|
||||
policeCheck: formData.policeCheck || null,
|
||||
driversLicenseClass: formData.driversLicenseClass,
|
||||
vehicleTypes: formData.vehicleTypes || [],
|
||||
skillSets: formData.skillSets || [],
|
||||
insuranceRequirements: formData.insuranceRequirements || [],
|
||||
emergencyContactName: formData.emergencyContactName,
|
||||
emergencyContactRelationship: formData.emergencyContactRelationship,
|
||||
emergencyContactTelephone: formData.emergencyContactTelephone,
|
||||
emergencyContactAlternativeTelephone:
|
||||
formData.emergencyContactAlternativeTelephone,
|
||||
description: formData.description,
|
||||
tags: formData.tags || [],
|
||||
gender: safeParseInt(formData.gender),
|
||||
genderOther: formData.genderOther,
|
||||
joinDate: formData.joinDate || null,
|
||||
birthDate: formData.birthDate || null,
|
||||
howDidYouHearAboutUsID: formData.howDidYouHearAboutUsID,
|
||||
isHowDidYouHearAboutUsOther: formData.isHowDidYouHearAboutUsOther,
|
||||
howDidYouHearAboutUsOther: formData.howDidYouHearAboutUsOther,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
identifyAs: Array.isArray(formData.identifyAs)
|
||||
? formData.identifyAs.map((id) => safeParseInt(id))
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Example usage of EntityUpdatePage for Staff
|
||||
* This shows how the reusable component can be configured for Staff entities
|
||||
*/
|
||||
const StaffUpdatePageExample = React.memo(function StaffUpdatePageExample() {
|
||||
const staffManager = useStaffManager();
|
||||
|
||||
// Memoize the manager object with its methods
|
||||
const manager = useMemo(
|
||||
() => ({
|
||||
getDetail: (id, onUnauthorized, options) =>
|
||||
staffManager.getStaffDetail(id, onUnauthorized, options),
|
||||
update: (id, data, onUnauthorized) =>
|
||||
staffManager.updateStaff(id, data, onUnauthorized),
|
||||
}),
|
||||
[staffManager],
|
||||
);
|
||||
|
||||
// Memoize the entire configuration object
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
// Basic entity information
|
||||
entityName: ENTITY_NAME,
|
||||
entityType: ENTITY_TYPE,
|
||||
idParam: ID_PARAM,
|
||||
|
||||
// Icons
|
||||
icon: UserGroupIcon,
|
||||
dashboardIcon: ChartBarIcon,
|
||||
detailIcon: InformationCircleIcon,
|
||||
updateIcon: PencilSquareIcon,
|
||||
|
||||
// Manager with CRUD operations
|
||||
manager,
|
||||
|
||||
// Initial form data structure - create new object from frozen template
|
||||
initialFormData: { ...INITIAL_FORM_DATA },
|
||||
|
||||
// Form sections to render
|
||||
formSections: FORM_SECTIONS,
|
||||
|
||||
// Validation, formatting functions
|
||||
validateForm,
|
||||
formatDataFromResponse,
|
||||
formatDataForSubmit,
|
||||
|
||||
// Custom breadcrumb items
|
||||
breadcrumbItems: BREADCRUMB_ITEMS,
|
||||
|
||||
// Custom tab items
|
||||
tabItems: TAB_ITEMS,
|
||||
}),
|
||||
[manager],
|
||||
);
|
||||
|
||||
return <EntityUpdatePage config={config} />;
|
||||
});
|
||||
|
||||
export default StaffUpdatePageExample;
|
||||
85
web/maplefile-frontend/src/components/UIX/Form/FormGroup.jsx
Normal file
85
web/maplefile-frontend/src/components/UIX/Form/FormGroup.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// File: src/components/UI/Form/FormGroup.jsx
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
/**
|
||||
* FormGroup Component - Performance Optimized
|
||||
* Container for form field with label, helper text, and error
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - Component memoization with React.memo
|
||||
* - Memoized sub-elements to prevent re-renders
|
||||
* - Optimized className concatenation
|
||||
* - Prevented icon re-renders
|
||||
*
|
||||
* @param {React.ReactNode} children - Form input element
|
||||
* @param {string} label - Field label
|
||||
* @param {string} error - Error message
|
||||
* @param {boolean} required - Whether field is required
|
||||
* @param {string} helperText - Helper text
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const FormGroup = memo(function FormGroup({
|
||||
children,
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
helperText,
|
||||
className = "",
|
||||
}) {
|
||||
// Memoize container className to prevent recalculation
|
||||
const containerClassName = useMemo(() => {
|
||||
const classes = ["mb-6"];
|
||||
if (className) classes.push(className);
|
||||
return classes.join(" ");
|
||||
}, [className]);
|
||||
|
||||
// Memoize label rendering
|
||||
const labelElement = useMemo(() => {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
);
|
||||
}, [label, required]);
|
||||
|
||||
// Memoize helper text - only show if no error
|
||||
const helperTextElement = useMemo(() => {
|
||||
if (!helperText || error) return null;
|
||||
|
||||
return <p className="mt-2 text-sm text-gray-500">{helperText}</p>;
|
||||
}, [helperText, error]);
|
||||
|
||||
// Memoize error message with icon
|
||||
const errorElement = useMemo(() => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<p className="mt-2 text-sm text-red-600 flex items-center">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-4 w-4 mr-1 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{labelElement}
|
||||
{children}
|
||||
{helperTextElement}
|
||||
{errorElement}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Set display name for React DevTools
|
||||
FormGroup.displayName = "FormGroup";
|
||||
|
||||
export default FormGroup;
|
||||
56
web/maplefile-frontend/src/components/UIX/Form/FormRow.jsx
Normal file
56
web/maplefile-frontend/src/components/UIX/Form/FormRow.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// File: src/components/UI/Form/FormRow.jsx
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* FormRow Component - Performance Optimized
|
||||
* Responsive grid container for form fields
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - Component memoization with React.memo
|
||||
* - Optimized className concatenation
|
||||
* - Children processing optimization
|
||||
* - Early return for empty renders
|
||||
*
|
||||
* @param {React.ReactNode} children - Form fields
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const FormRow = memo(function FormRow({ children, className = "" }) {
|
||||
// Early return if no children to prevent unnecessary renders
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Memoize the combined className string
|
||||
const containerClassName = useMemo(() => {
|
||||
const baseClasses = "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4";
|
||||
return className ? `${baseClasses} ${className}` : baseClasses;
|
||||
}, [className]);
|
||||
|
||||
// Process children only when they change
|
||||
const processedChildren = useMemo(() => {
|
||||
// For single child, return as-is
|
||||
if (!Array.isArray(children)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// For array of children, filter out null/undefined/false values
|
||||
// React.Children.toArray automatically handles keys and flattening
|
||||
const validChildren = React.Children.toArray(children).filter(Boolean);
|
||||
|
||||
// Return null if all children are invalid
|
||||
return validChildren.length > 0 ? validChildren : null;
|
||||
}, [children]);
|
||||
|
||||
// Don't render if no valid children after processing
|
||||
if (!processedChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={containerClassName}>{processedChildren}</div>;
|
||||
});
|
||||
|
||||
// Set display name for React DevTools
|
||||
FormRow.displayName = "FormRow";
|
||||
|
||||
export default FormRow;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// File: src/components/UI/Form/FormSection.jsx
|
||||
|
||||
import React, { memo, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* FormSection Component - Performance Optimized
|
||||
* Section container with title and description
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - Component memoization with React.memo
|
||||
* - Memoized sub-elements to prevent re-renders
|
||||
* - Optimized className concatenation
|
||||
* - Children processing optimization
|
||||
* - Early return for empty sections
|
||||
*
|
||||
* @param {string} title - Section title
|
||||
* @param {string} description - Section description
|
||||
* @param {React.ReactNode} children - Section content
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const FormSection = memo(function FormSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className = "",
|
||||
}) {
|
||||
// Early return if completely empty section
|
||||
if (!title && !description && !children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Memoize container className
|
||||
const containerClassName = useMemo(() => {
|
||||
const baseClass = "mb-8";
|
||||
return className ? `${baseClass} ${className}` : baseClass;
|
||||
}, [className]);
|
||||
|
||||
// Memoize title element
|
||||
const titleElement = useMemo(() => {
|
||||
if (!title) return null;
|
||||
|
||||
return <h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>;
|
||||
}, [title]);
|
||||
|
||||
// Memoize description element
|
||||
const descriptionElement = useMemo(() => {
|
||||
if (!description) return null;
|
||||
|
||||
return <p className="text-sm text-gray-600 mb-4">{description}</p>;
|
||||
}, [description]);
|
||||
|
||||
// Memoize children wrapper with space-y-4 styling
|
||||
const childrenWrapper = useMemo(() => {
|
||||
if (!children) return null;
|
||||
|
||||
// Process children to handle arrays and filter out null values
|
||||
let processedChildren = children;
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
// Use React.Children.toArray for proper key handling and filtering
|
||||
processedChildren = React.Children.toArray(children).filter(Boolean);
|
||||
|
||||
// If no valid children after filtering, return null
|
||||
if (processedChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="space-y-4">{processedChildren}</div>;
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
{childrenWrapper}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Set display name for React DevTools
|
||||
FormSection.displayName = "FormSection";
|
||||
|
||||
export default FormSection;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue