Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View 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

View 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]

View 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

View 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

View 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

View 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

View 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
View 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

View 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.

View 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~

View 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 },
],
},
},
];

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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');
}

View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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";

View file

@ -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;

View file

@ -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 };

View file

@ -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 };

View 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;

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './AttachmentsView.jsx';

View 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 };

View file

@ -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;

View file

@ -0,0 +1 @@
export { default as BackButton } from './BackButton.jsx';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './BackToDetailsButton';

View file

@ -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",
});

View file

@ -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;

View file

@ -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.

View file

@ -0,0 +1 @@
export { default } from "./BackupCodeDisplay";

View 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",
});

View file

@ -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
*/

View 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",
});

View 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;

View file

@ -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;

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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 };

View file

@ -0,0 +1 @@
export { default as CommentsView } from './CommentsView.jsx';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View 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.

View file

@ -0,0 +1,2 @@
// File: src/components/UIX/DataList/index.jsx
export { default } from "./DataList";

View 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 };

View file

@ -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;

View 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 };

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 };

View file

@ -0,0 +1 @@
export { default as DetailFullView } from './DetailFullView.jsx';

View file

@ -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 };

View file

@ -0,0 +1 @@
export { default as DetailLiteView } from './DetailLiteView.jsx';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './EntityAttachmentAddPage';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './EntityAttachmentDetailPage.jsx';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './EntityAttachmentListPage.jsx';

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './EntityAttachmentUpdatePage.jsx';

View file

@ -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;

View file

@ -0,0 +1,2 @@
export { default as EntityCommentsPage } from './EntityCommentsPage';
export { default } from './EntityCommentsPage';

View file

@ -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;

View file

@ -0,0 +1,3 @@
// File: src/components/UIX/EntityFileView/index.jsx
export { default as EntityFileView } from "./EntityFileView.jsx";
export { default } from "./EntityFileView.jsx";

View file

@ -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;

View file

@ -0,0 +1,4 @@
// File: src/components/UIX/EntityListPage/index.jsx
export { default } from "./EntityListPage";
export { default as EntityListPage } from "./EntityListPage";

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -0,0 +1,4 @@
// File: src/components/UIX/EntityReportDetail/index.jsx
export { default } from "./EntityReportDetail";
export { default as EntityReportDetail } from "./EntityReportDetail";

View file

@ -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);

View file

@ -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>
);
},
);

View file

@ -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>
);
});

View file

@ -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>
);
},
);

View file

@ -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;

View file

@ -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>
);
});

View file

@ -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;

View 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;

View 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;

View file

@ -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