first commit
This commit is contained in:
commit
2cb8005540
320
.cursor/rules/qassurerules.mdc
Normal file
320
.cursor/rules/qassurerules.mdc
Normal file
@ -0,0 +1,320 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# QAssure.ai Management Console - Development Rules
|
||||
|
||||
## Initial Response Protocol
|
||||
Let's Start Building Frontend
|
||||
|
||||
## Scope Control & Permission Protocol (CRITICAL)
|
||||
|
||||
### Strict Scope Adherence
|
||||
1. **ONLY** work on the specific component/feature explicitly requested
|
||||
2. **NEVER** create additional components, files, or features without explicit permission
|
||||
3. **STOP** after completing the requested task - do not continue or expand scope
|
||||
4. **ASK** before doing anything beyond the original instruction
|
||||
|
||||
### When to Ask Permission
|
||||
You MUST ask for explicit approval before:
|
||||
- Creating any file not explicitly requested
|
||||
- Adding any dependencies not mentioned in the request
|
||||
- Implementing related/adjacent features "to be helpful"
|
||||
- Refactoring existing code not part of the request
|
||||
- Creating utility functions, hooks, or services beyond scope
|
||||
- Setting up folder structures not specifically asked for
|
||||
- Adding tests, documentation, or configuration files
|
||||
|
||||
### Completion Protocol
|
||||
After finishing the requested component/task:
|
||||
1. Confirm completion: "✅ [Component Name] has been created"
|
||||
2. List what was delivered (files created, dependencies used)
|
||||
3. **STOP and WAIT** for next instruction
|
||||
5. **DO NOT** ask "Should I create X next?" - wait for explicit request
|
||||
|
||||
## Technology Stack (STRICT - No Deviations)
|
||||
- **Framework**: React 19.2.0 (Functional Components Only)
|
||||
- **Build Tool**: Vite 7.2.4
|
||||
- **Language**: TypeScript 5.9.3 (Strict Mode)
|
||||
- **Styling**: Tailwind CSS 4.1.18
|
||||
- **Routing**: React Router DOM 7.12.0
|
||||
- **State Management**: Redux Toolkit 2.11.2
|
||||
- **UI Components**: shadcn/ui (Radix UI primitives)
|
||||
- **Forms**: React Hook Form 7.71.1
|
||||
- **API Client**: Axios 1.13.2
|
||||
- **Charts**: Recharts 3.6.0
|
||||
- **Icons**: Lucide React 0.562.0
|
||||
|
||||
## Dependency Management Rules
|
||||
1. **NEVER** install libraries not listed in package.json without explicit approval, Only Install with permission.
|
||||
2. Check `package.json` before suggesting any import
|
||||
3. Use only existing dependencies; suggest additions only when critical
|
||||
4. All imports must use exact package names from package.json
|
||||
|
||||
## Figma MCP Automation Workflow
|
||||
1. **Design Fetch**: Use Figma MCP to retrieve component designs
|
||||
2. **Analysis Phase**: Identify component structure, variants, and responsive breakpoints
|
||||
3. **Implementation**: Convert Figma designs to shadcn/ui + Tailwind CSS components
|
||||
4. **Validation**: Ensure pixel-perfect accuracy across all breakpoints (320px - 1920px)
|
||||
5. **Never** hardcode colors/spacing - extract to Tailwind config or CSS variables
|
||||
|
||||
## Code Architecture & File Structure (MANDATORY)
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ └── shared/ # Custom shared components
|
||||
├── features/ # Feature-based modules (Dashboard, Modules, AI, etc.)
|
||||
│ └── [feature]/
|
||||
│ ├── components/ # Feature-specific components
|
||||
│ ├── hooks/ # Feature-specific hooks
|
||||
│ └── services/ # Feature-specific API calls
|
||||
├── hooks/ # Global custom hooks
|
||||
├── services/ # API integration layer (Axios instances)
|
||||
├── store/ # Redux store, slices, actions
|
||||
├── types/ # TypeScript type definitions
|
||||
├── utils/ # Utility/helper functions
|
||||
├── constants/ # App constants, enums
|
||||
├── styles/ # Global styles, Tailwind config
|
||||
└── pages/ # Route-level page components
|
||||
```
|
||||
|
||||
## File Size Constraints (HARD LIMITS)
|
||||
- **React Components**: Maximum 500 lines (including imports/exports)
|
||||
- **Hooks/Utils/Services**: Maximum 200 lines per file
|
||||
- **Types**: Maximum 300 lines per file
|
||||
- **Violation Action**: MUST refactor into smaller, focused modules
|
||||
|
||||
## Component Development Standards
|
||||
|
||||
### Component Structure Template
|
||||
```tsx
|
||||
// 1. Imports (grouped: React, external, internal, types, styles)
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import type { ModuleStatus } from '@/types/module';
|
||||
|
||||
// 2. Types/Interfaces
|
||||
interface ComponentProps {
|
||||
title: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
// 3. Component (functional, typed props, return type)
|
||||
export const ComponentName = ({ title, onAction }: ComponentProps): JSX.Element => {
|
||||
// Hooks first
|
||||
const [state, setState] = useState<string>('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Event handlers
|
||||
const handleClick = () => {
|
||||
// Implementation
|
||||
};
|
||||
|
||||
// Early returns for conditional rendering
|
||||
if (!title) return <div>Loading...</div>;
|
||||
|
||||
// Main render
|
||||
return <div>{/* JSX */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Component Rules
|
||||
- **Functional components only** (no class components)
|
||||
- **Props destructuring** in function signature
|
||||
- **Explicit return types**: `: JSX.Element` or `: React.ReactNode`
|
||||
- **Custom hooks** for reusable logic (prefix with `use`)
|
||||
- **Compound components** for complex UIs (e.g., `Card.Header`, `Card.Body`)
|
||||
|
||||
## Naming Conventions (NON-NEGOTIABLE)
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| Component | PascalCase | `UserDashboard`, `ModuleCatalog` |
|
||||
| Function/Hook | camelCase | `getUserById`, `useModuleStatus` |
|
||||
| Variable | camelCase | `moduleList`, `isActive` |
|
||||
| Constant | SCREAMING_SNAKE | `API_BASE_URL`, `MAX_RETRIES` |
|
||||
| Type/Interface | PascalCase | `ModuleConfig`, `UserRole` |
|
||||
| File (Component) | PascalCase | `ModuleCatalog.tsx` |
|
||||
| File (Other) | kebab-case | `api-client.ts`, `format-date.ts` |
|
||||
| CSS Class | kebab-case | `module-card`, `status-badge` |
|
||||
|
||||
### Boolean Naming
|
||||
Prefix with `is`, `has`, `can`, `should`: `isLoading`, `hasPermission`, `canEdit`
|
||||
|
||||
## Responsive Design (Tailwind Mobile-First)
|
||||
|
||||
### Breakpoints (Tailwind 4)
|
||||
```
|
||||
Default (mobile): 320px - 767px (no prefix)
|
||||
md: ≥768px (Tablet)
|
||||
lg: ≥1024px (Laptop)
|
||||
xl: ≥1440px (Desktop)
|
||||
2xl: ≥1920px (Large Display)
|
||||
```
|
||||
|
||||
### Implementation Rules
|
||||
1. **Mobile-first**: Start with mobile layout, enhance with `md:`, `lg:`, `xl:`, `2xl:`
|
||||
2. **Touch targets**: Minimum `min-h-[44px] min-w-[44px]` for interactive elements
|
||||
3. **Responsive patterns**:
|
||||
```tsx
|
||||
// Layout
|
||||
<div className="flex flex-col md:flex-row lg:gap-6">
|
||||
|
||||
// Grid
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
|
||||
// Typography
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl xl:text-5xl">
|
||||
|
||||
// Spacing
|
||||
<div className="p-4 md:p-6 lg:p-8">
|
||||
```
|
||||
4. **NEVER** use fixed pixel widths; use `w-full`, `max-w-*`, percentage-based utilities
|
||||
|
||||
## State Management (Redux Toolkit)
|
||||
|
||||
### Slice Structure
|
||||
```typescript
|
||||
// features/modules/store/moduleSlice.ts
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
export const fetchModules = createAsyncThunk('modules/fetch', async () => {
|
||||
// API call
|
||||
});
|
||||
|
||||
const moduleSlice = createSlice({
|
||||
name: 'modules',
|
||||
initialState: { items: [], loading: false, error: null },
|
||||
reducers: { /* sync actions */ },
|
||||
extraReducers: (builder) => { /* async actions */ }
|
||||
});
|
||||
```
|
||||
|
||||
### Usage Rules
|
||||
- **Async operations**: Use `createAsyncThunk`
|
||||
- **Selectors**: Create memoized selectors with `createSelector`
|
||||
- **Typed hooks**: Use `useAppDispatch`, `useAppSelector` (typed versions)
|
||||
|
||||
## API Communication (Axios)
|
||||
|
||||
### Service Layer Pattern
|
||||
```typescript
|
||||
// services/api/module-service.ts
|
||||
import apiClient from '@/services/api-client';
|
||||
import type { Module, CreateModuleDto } from '@/types/module';
|
||||
|
||||
export const moduleService = {
|
||||
getAll: () => apiClient.get<Module[]>('/modules'),
|
||||
getById: (id: string) => apiClient.get<Module>(`/modules/${id}`),
|
||||
create: (data: CreateModuleDto) => apiClient.post<Module>('/modules', data),
|
||||
};
|
||||
```
|
||||
|
||||
### Rules
|
||||
- **Centralized instance**: Single Axios instance with interceptors
|
||||
- **Error handling**: Global error interceptor + component-level handling
|
||||
- **Type safety**: Always type API responses
|
||||
- **Loading states**: Track in Redux or local state
|
||||
|
||||
## Form Handling (React Hook Form)
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({ name: z.string().min(1) });
|
||||
|
||||
const MyForm = () => {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Component Error Boundaries
|
||||
```tsx
|
||||
// Wrap async components
|
||||
<ErrorBoundary fallback={<ErrorFallback />}>
|
||||
<SuspendedComponent />
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
### Try-Catch Pattern
|
||||
```typescript
|
||||
try {
|
||||
await apiCall();
|
||||
} catch (error) {
|
||||
console.error('Context:', error);
|
||||
// User-friendly message
|
||||
toast.error('Failed to load data. Please try again.');
|
||||
}
|
||||
```
|
||||
|
||||
## Code Quality Rules
|
||||
1. **DRY**: Extract repeated logic into utils/hooks
|
||||
2. **SRP**: One purpose per file/component/function
|
||||
3. **KISS**: Prefer clarity over cleverness
|
||||
4. **Max nesting**: 3 levels; refactor if deeper
|
||||
5. **No any**: Use proper TypeScript types
|
||||
6. **No inline styles**: Use Tailwind classes only
|
||||
|
||||
## Performance Optimization
|
||||
- **Code splitting**: Lazy load routes with `React.lazy()`
|
||||
- **Memoization**: Use `useMemo`, `useCallback`, `React.memo` appropriately
|
||||
- **List virtualization**: Use `react-window` for long lists (>100 items)
|
||||
- **Image optimization**: WebP format, lazy loading, responsive images
|
||||
|
||||
## Security Guidelines
|
||||
- **Never** store tokens in localStorage (use httpOnly cookies)
|
||||
- **Validate inputs**: Client-side + server-side validation
|
||||
- **Sanitize outputs**: Prevent XSS with proper escaping
|
||||
- **CORS**: Configure allowed origins properly
|
||||
|
||||
## Prohibited Practices
|
||||
1. ❌ Class components
|
||||
2. ❌ Inline function definitions in JSX (unless trivial)
|
||||
3. ❌ `any` type without explicit justification
|
||||
4. ❌ Direct DOM manipulation (use refs sparingly)
|
||||
5. ❌ Hardcoded strings (use constants/i18n)
|
||||
6. ❌ Magic numbers (define as constants)
|
||||
7. ❌ Unused imports/variables
|
||||
8. ❌ Console.log in production code (use proper logging)
|
||||
9. ❌ **Creating components/files beyond the specific request**
|
||||
10. ❌ **Expanding scope without explicit permission**
|
||||
11. ❌ **Installing dependencies without asking first**
|
||||
|
||||
## Documentation Policy
|
||||
**DO NOT** create documentation (README, API docs, guides) unless explicitly requested.
|
||||
Focus solely on code implementation following these rules.
|
||||
|
||||
## Code Review Checklist (Self-Audit)
|
||||
- [ ] Follows file structure and naming conventions?
|
||||
- [ ] Within file size limits?
|
||||
- [ ] Fully responsive (320px - 1920px)?
|
||||
- [ ] Proper TypeScript typing (no `any`)?
|
||||
- [ ] Error handling implemented?
|
||||
- [ ] No code duplication?
|
||||
- [ ] Accessible (ARIA labels, keyboard navigation)?
|
||||
- [ ] Performance optimized (memoization, lazy loading)?
|
||||
|
||||
## Specific Instructions
|
||||
1. When fetching Figma designs, analyze component hierarchy first
|
||||
2. Generate TypeScript interfaces from Figma component properties
|
||||
3. Map Figma styles to Tailwind classes (use closest utility)
|
||||
4. Create responsive variants based on Figma frames
|
||||
5. Suggest shadcn/ui components that match design patterns
|
||||
6. Always ask for clarification if Figma design is ambiguous
|
||||
7. Provide component usage examples after generation
|
||||
8. **CRITICAL**: Ask permission before installing any new dependencies or shadcn components
|
||||
9. **CRITICAL**: If you identify missing utilities/types needed, ASK before creating them
|
||||
|
||||
---
|
||||
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
components.json
Normal file
22
components.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
491
docs/project-context.md
Normal file
491
docs/project-context.md
Normal file
@ -0,0 +1,491 @@
|
||||
# QAssure.ai Management Console - Project Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Product Name**: QAssure.ai Platform Management Console
|
||||
**Organization**: L&T Technology Services - Enterprise Quality Management Division
|
||||
**Version**: 1.0
|
||||
**Document Date**: December 2025
|
||||
**Development Phase**: Frontend Implementation
|
||||
|
||||
### What is QAssure.ai?
|
||||
|
||||
QAssure.ai is an AI-powered enterprise quality management platform designed for regulated industries (medical devices, pharmaceuticals, life sciences). The platform uses artificial intelligence to automate quality management tasks including complaint handling, clinical evaluations, design history files, and regulatory compliance.
|
||||
|
||||
### What is the Management Console?
|
||||
|
||||
The Management Console is a web-based application that provides a visual, user-friendly interface for managing and monitoring the entire QAssure.ai platform. It serves as the "control center" where administrators, operators, and support teams can manage the platform without writing code or using command-line tools.
|
||||
|
||||
**Key Principle**: The Management Console complements (not replaces) the existing CLI and SDK tools, providing a visual alternative for users who prefer graphical interfaces.
|
||||
|
||||
---
|
||||
|
||||
## User Personas & Use Cases
|
||||
|
||||
### 1. Platform Administrators
|
||||
**Role**: Senior technical staff responsible for platform health and security
|
||||
**Primary Tasks**:
|
||||
- Configure platform-wide settings
|
||||
- Manage user accounts and permissions
|
||||
- Monitor system health
|
||||
- Ensure security compliance
|
||||
- Respond to critical incidents
|
||||
|
||||
**Console Features They Use**:
|
||||
- Main Dashboard (health overview)
|
||||
- User and Access Management
|
||||
- System Configuration
|
||||
- Audit Logs
|
||||
- Security Settings
|
||||
|
||||
### 2. DevOps Engineers
|
||||
**Role**: Technical staff deploying and maintaining infrastructure
|
||||
**Primary Tasks**:
|
||||
- Deploy new module versions
|
||||
- Scale services based on demand
|
||||
- Monitor server resources
|
||||
- Troubleshoot performance issues
|
||||
- Manage deployment pipelines
|
||||
|
||||
**Console Features They Use**:
|
||||
- Module Management (deployment wizard)
|
||||
- System Health Monitoring
|
||||
- Log Viewer
|
||||
- Request Tracing
|
||||
- Performance Metrics
|
||||
|
||||
### 3. AI/ML Engineers
|
||||
**Role**: Specialists managing AI components
|
||||
**Primary Tasks**:
|
||||
- Configure AI models and providers
|
||||
- Manage prompts (AI instructions)
|
||||
- Monitor AI performance and costs
|
||||
- Optimize AI response quality
|
||||
- Manage knowledge bases (RAG)
|
||||
|
||||
**Console Features They Use**:
|
||||
- AI Management (providers, models)
|
||||
- Prompt Management (editor, playground)
|
||||
- Knowledge Base Management
|
||||
- AI Cost Tracking
|
||||
- AI Usage Analytics
|
||||
|
||||
### 4. Support Engineers
|
||||
**Role**: Staff responding to user issues
|
||||
**Primary Tasks**:
|
||||
- Investigate reported issues
|
||||
- Search system logs
|
||||
- Identify error causes
|
||||
- Trace request flows
|
||||
- Coordinate fixes with teams
|
||||
|
||||
**Console Features They Use**:
|
||||
- Log Viewer
|
||||
- Request Tracing
|
||||
- Alerts and Notifications
|
||||
- System Health Monitoring
|
||||
- Troubleshooting Tools
|
||||
|
||||
### 5. Tenant Administrators
|
||||
**Role**: Customer organization administrators
|
||||
**Primary Tasks**:
|
||||
- Manage their organization's users
|
||||
- Configure tenant-specific settings
|
||||
- View usage reports
|
||||
- Integrate with existing QMS systems
|
||||
- Monitor their environment health
|
||||
|
||||
**Console Features They Use**:
|
||||
- Tenant Dashboard
|
||||
- QMS Integration Setup
|
||||
- Usage Reports
|
||||
- User Management (tenant-scoped)
|
||||
|
||||
---
|
||||
|
||||
## Core Feature Areas
|
||||
|
||||
### 1. Main Dashboard (Landing Page)
|
||||
|
||||
**Purpose**: Instant overview of entire platform status at a glance
|
||||
|
||||
**Key Components**:
|
||||
- **Platform Health Score** (0-100): Single metric aggregating all system health indicators (color-coded: green/yellow/red)
|
||||
- **Module Status Display**: Visual cards for each module (AiCE, AiCH, DHF, SQ) showing status (Active, Degraded, Suspended, Offline)
|
||||
- **Real-Time Activity Metrics**: Live graphs updating every few seconds (requests/sec, response times, error rates)
|
||||
- **Recent Alerts Panel**: List of recent issues requiring attention with severity indicators
|
||||
- **AI Usage Summary**: Today's AI requests, token consumption, estimated costs
|
||||
- **Quick Actions**: Buttons for frequent tasks (deploy module, create tenant, scale service)
|
||||
|
||||
**Design Considerations**:
|
||||
- Information density: Show maximum relevant data without overwhelming
|
||||
- Glanceability: Critical status visible within 3 seconds
|
||||
- Actionability: Direct access to common tasks from dashboard
|
||||
|
||||
### 2. Module Management
|
||||
|
||||
**Purpose**: Control and monitor all QAssure platform modules
|
||||
|
||||
**Modules in QAssure.ai**:
|
||||
- **AiCE**: AI-powered Complaint Evaluation
|
||||
- **AiCH**: AI-powered Clinical Evaluation
|
||||
- **DHF**: Design History File Management
|
||||
- **SQ**: Supplier Quality Management
|
||||
- **Custom Modules**: Tenant-specific or future modules
|
||||
|
||||
**Key Features**:
|
||||
|
||||
#### Module Catalog
|
||||
- Browsable library of all modules
|
||||
- Each module shows: name, description, icon, version, status, tech stack, resource usage, dependencies
|
||||
- Search and filter capabilities
|
||||
- Click-through to detailed module pages
|
||||
|
||||
#### Deployment Wizard
|
||||
Multi-step process for safe module updates:
|
||||
1. Select module and target version
|
||||
2. Review changelog (what's new/fixed)
|
||||
3. Choose deployment strategy:
|
||||
- **Blue-Green**: New version runs alongside old; traffic gradually shifts
|
||||
- **Immediate**: Instant switch (for urgent hotfixes)
|
||||
4. Configure traffic split (e.g., start with 10% of users)
|
||||
5. Review settings and confirm
|
||||
6. Monitor deployment progress with real-time health checks
|
||||
7. **Safety Features**: One-click rollback, automatic health validation, approval workflows
|
||||
|
||||
#### Module Lifecycle Management
|
||||
Transition modules through stages:
|
||||
- **Development** → **Testing** → **Staging** → **Active** → **Suspended** → **Deprecated** → **Retired**
|
||||
- Actions: Suspend (immediately stop traffic), Deprecate (notify users of retirement date), Retire (permanent shutdown)
|
||||
|
||||
### 3. AI Management
|
||||
|
||||
**Purpose**: Configure, monitor, and optimize AI capabilities
|
||||
|
||||
**Key Features**:
|
||||
|
||||
#### AI Providers and Models
|
||||
- **Supported Providers**: OpenAI (GPT-4), Anthropic (Claude), Google (Gemini)
|
||||
- **Configuration**: Add/edit API keys, set spending limits, configure backup providers
|
||||
- **Model Routing**: Assign specific models to different tasks (complex tasks → powerful model; simple tasks → cheaper model)
|
||||
- **Health Monitoring**: Real-time status and response time for each provider
|
||||
|
||||
#### Prompt Management
|
||||
- **Prompt Editor**: Specialized text editor with syntax highlighting, variable placeholders, version history, side-by-side comparison
|
||||
- **Testing Playground**: Test prompts with sample inputs, compare responses across models, measure performance, save test cases
|
||||
- **Version Control**: Track all changes, revert to previous versions, see diff between versions
|
||||
|
||||
#### Knowledge Base Management (RAG)
|
||||
RAG = Retrieval-Augmented Generation (giving AI access to specific documents for accurate responses)
|
||||
- **Document Upload**: PDFs, Word docs, web pages
|
||||
- **Organization**: Collections for different purposes (regulations, manuals, policies)
|
||||
- **Processing Status**: Track document processing progress
|
||||
- **Search Testing**: Verify AI can find relevant information
|
||||
- **Cleanup**: Remove outdated documents
|
||||
|
||||
#### AI Cost Tracking
|
||||
- **Breakdowns**: By provider, by module, by tenant, by use case
|
||||
- **Time Ranges**: Today, week, month, custom
|
||||
- **Trends**: Visualize usage patterns over time
|
||||
- **Alerts**: Notify when spending exceeds thresholds
|
||||
|
||||
### 4. Tenant Management
|
||||
|
||||
**Purpose**: Manage multiple customer organizations (multi-tenancy)
|
||||
|
||||
**Key Concepts**:
|
||||
- **Multi-tenant Architecture**: Single platform instance serves multiple customers with isolated data
|
||||
- **Tenant**: A customer organization with its own isolated environment
|
||||
|
||||
**Key Features**:
|
||||
|
||||
#### Tenant Onboarding Wizard
|
||||
6-step process:
|
||||
1. Company information (name, industry, contact)
|
||||
2. Subscription plan selection
|
||||
3. Module access configuration
|
||||
4. Initial admin user setup
|
||||
5. QMS integration settings
|
||||
6. Review and create
|
||||
|
||||
#### Tenant Dashboard
|
||||
- List of all tenants with status (active, suspended, trial)
|
||||
- Usage metrics per tenant (API calls, AI usage, storage)
|
||||
- Subscription details and renewal dates
|
||||
- Health status indicators
|
||||
- Recent activity and support tickets
|
||||
|
||||
#### QMS Integration Setup
|
||||
Connect QAssure to existing Quality Management Systems:
|
||||
- **Supported Systems**: Veeva Vault, MasterControl, Arena PLM, TrackWise, others
|
||||
- **Configuration**: API credentials, field mapping (map QAssure fields to QMS fields), sync settings (frequency, direction, conflict resolution)
|
||||
- **Testing**: Validate connection before activation
|
||||
- **Monitoring**: View sync status and errors
|
||||
|
||||
### 5. User and Access Management
|
||||
|
||||
**Purpose**: Control who can access the platform and what they can do
|
||||
|
||||
**Key Features**:
|
||||
|
||||
#### User Management
|
||||
- User directory (all users with roles, last login, status)
|
||||
- CRUD operations (create, edit, deactivate accounts)
|
||||
- Password reset and account unlocking
|
||||
- Activity logs (login history, actions performed)
|
||||
|
||||
#### Roles and Permissions (RBAC)
|
||||
- **Role-Based Access Control**: Users assigned roles; roles contain permissions
|
||||
- **Built-in Roles**: Administrator, Operator, Viewer, Module Admin, Tenant Admin
|
||||
- **Custom Roles**: Create organization-specific roles
|
||||
- **Permission Editor**: Visual grid showing resources × actions with checkboxes
|
||||
- **Effective Permissions**: See exactly what a user can do (combined from all roles)
|
||||
|
||||
#### Single Sign-On (SSO)
|
||||
- **Supported Providers**: Microsoft Azure AD, Okta, Auth0, Google Workspace, any SAML 2.0 / OAuth 2.0 provider
|
||||
- **Configuration Wizard**: Step-by-step SSO setup
|
||||
- **User Experience**: Redirect to corporate login page, seamless authentication
|
||||
|
||||
### 6. Monitoring and Troubleshooting
|
||||
|
||||
**Purpose**: Understand platform behavior and diagnose issues
|
||||
|
||||
**Key Features**:
|
||||
|
||||
#### System Health Monitoring
|
||||
Real-time metrics:
|
||||
- **Servers**: CPU, memory, disk, network traffic
|
||||
- **Databases**: Connections, query performance, storage
|
||||
- **Caches**: Hit rate, memory usage
|
||||
- **Message Queues**: Queue depth, consumer lag
|
||||
- **APIs**: Requests/sec, response times, error rates
|
||||
|
||||
Visualization: Time-series charts, zoom/pan, custom time ranges, comparison views
|
||||
|
||||
#### Log Viewer
|
||||
- **Search**: Keyword, error code, free-text search
|
||||
- **Filters**: Time range, module, severity (error/warning/info), tenant
|
||||
- **Live Tail**: Watch logs in real-time
|
||||
- **Export**: Download for offline analysis
|
||||
- **Saved Searches**: Bookmark frequent investigations
|
||||
|
||||
#### Request Tracing (Distributed Tracing)
|
||||
- **Purpose**: Follow a single request through multiple microservices
|
||||
- **Timeline View**: See time spent in each service
|
||||
- **Bottleneck Identification**: Spot slow components
|
||||
- **Drill-Down**: Click any step for detailed logs
|
||||
- **Use Case Example**: "Report generation is slow" → Trace request → Find AI call took 10s → Optimize
|
||||
|
||||
#### Alerts and Notifications
|
||||
- **Alert Types**: Threshold (CPU > 80%), Anomaly (unusual pattern), Availability (service down), Error Rate (errors > 5%)
|
||||
- **Notification Channels**: Email, Slack, PagerDuty, SMS, In-app
|
||||
- **Configuration**: Create custom alerts with conditions and channels
|
||||
- **Alert Management**: View, acknowledge, resolve alerts
|
||||
|
||||
### 7. Reporting and Analytics
|
||||
|
||||
**Purpose**: Insights for decision-making and stakeholder communication
|
||||
|
||||
**Report Types**:
|
||||
|
||||
#### Usage Reports
|
||||
- Total API calls over time
|
||||
- Active users and login frequency
|
||||
- Most-used features
|
||||
- Storage consumption by tenant
|
||||
- AI usage (requests, tokens, costs)
|
||||
|
||||
**Users**: Account managers, product managers, finance teams, capacity planners
|
||||
|
||||
#### Performance Reports
|
||||
- **Availability**: Uptime percentage
|
||||
- **Response Time**: Average, P50, P95, P99
|
||||
- **Error Rate**: Percentage of failed requests
|
||||
- **SLA Compliance**: Met/missed service level objectives
|
||||
|
||||
**Users**: Operations teams, customer success managers, engineering teams
|
||||
|
||||
#### Cost Reports
|
||||
- Infrastructure costs (cloud compute, storage, networking)
|
||||
- AI costs by provider, model, use case
|
||||
- Cost allocation by tenant/department
|
||||
- Cost trends and forecasts
|
||||
|
||||
**Export Formats**: PDF, Excel, CSV, Scheduled email delivery
|
||||
|
||||
### 8. System Configuration
|
||||
|
||||
**Purpose**: Platform-wide settings affecting all users
|
||||
|
||||
**Categories**:
|
||||
|
||||
#### General Settings
|
||||
- Platform name and branding (logo, colors)
|
||||
- Default timezone and date format
|
||||
- Session timeout duration
|
||||
- Email settings (SMTP server)
|
||||
- Default UI language
|
||||
|
||||
#### Security Settings
|
||||
- Password policies (length, complexity, expiration)
|
||||
- Multi-factor authentication (MFA) requirements
|
||||
- IP allowlists (restrict access by IP)
|
||||
- Session management (max concurrent sessions)
|
||||
- API rate limits (prevent abuse)
|
||||
|
||||
#### Integration Settings
|
||||
- API gateway configuration (timeouts, retries)
|
||||
- Event bus settings (message retention)
|
||||
- External service connections
|
||||
- Webhook configurations
|
||||
|
||||
#### Audit Log
|
||||
- Immutable record of all administrative actions
|
||||
- Tracks: Who, What, When for every change
|
||||
- Retention: 7 years (compliance requirement)
|
||||
- Searchable and exportable
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture Overview
|
||||
|
||||
### Platform Components
|
||||
- **Frontend**: React-based Management Console (this project)
|
||||
- **Backend**: Microservices architecture (out of scope for frontend)
|
||||
- **Modules**: Independent services (AiCE, AiCH, DHF, SQ)
|
||||
- **AI Layer**: Integration with OpenAI, Anthropic, Google AI
|
||||
- **Data Layer**: Databases, caches, message queues
|
||||
- **Integration Layer**: API gateway, event bus, external QMS connectors
|
||||
|
||||
### Multi-Tenancy Model
|
||||
- **Data Isolation**: Each tenant's data completely separated
|
||||
- **Resource Sharing**: Compute and infrastructure shared for efficiency
|
||||
- **Customization**: Per-tenant configuration and branding
|
||||
|
||||
### Authentication & Authorization Flow
|
||||
1. User logs in (SSO or username/password)
|
||||
2. Backend validates credentials
|
||||
3. JWT token issued (httpOnly cookie)
|
||||
4. Frontend reads user roles and permissions
|
||||
5. UI renders based on permissions
|
||||
6. API calls include token in Authorization header
|
||||
7. Backend validates token and permissions for each request
|
||||
|
||||
---
|
||||
|
||||
## Design System & UI Patterns
|
||||
|
||||
### Visual Design Principles
|
||||
- **Clarity**: Information hierarchy is obvious
|
||||
- **Consistency**: Same patterns throughout
|
||||
- **Efficiency**: Minimize clicks to complete tasks
|
||||
- **Feedback**: Immediate response to user actions
|
||||
- **Accessibility**: WCAG 2.1 AA compliant
|
||||
|
||||
### Color Semantics
|
||||
- **Green**: Healthy, success, active
|
||||
- **Yellow/Amber**: Warning, degraded, attention needed
|
||||
- **Red**: Error, critical, offline
|
||||
- **Blue**: Informational, primary actions
|
||||
- **Gray**: Neutral, disabled, secondary
|
||||
|
||||
### Component Patterns
|
||||
- **Cards**: Module status, tenant summaries, metric displays
|
||||
- **Tables**: User lists, log entries, audit records
|
||||
- **Graphs/Charts**: Time-series metrics, usage trends, cost breakdowns
|
||||
- **Wizards**: Multi-step processes (deployment, onboarding)
|
||||
- **Forms**: Configuration, user creation, settings
|
||||
- **Modals/Dialogs**: Confirmations, quick edits
|
||||
- **Toasts/Notifications**: Success/error feedback
|
||||
|
||||
### Layout Patterns
|
||||
- **Dashboard Layout**: Grid of metric cards + quick actions
|
||||
- **Master-Detail**: List on left, details on right (e.g., module catalog)
|
||||
- **Tabbed Interface**: Organize related settings (AI Management tabs: Providers, Prompts, Knowledge Base, Costs)
|
||||
- **Full-Screen Views**: Log viewer, request tracing (maximize information density)
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Requirements
|
||||
|
||||
### Performance Targets
|
||||
- **First Contentful Paint (FCP)**: < 1.8s
|
||||
- **Largest Contentful Paint (LCP)**: < 2.5s
|
||||
- **Time to Interactive (TTI)**: < 3.8s
|
||||
- **Bundle Size (main)**: < 250KB gzipped
|
||||
|
||||
### Browser Support
|
||||
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest 2 versions)
|
||||
- **No IE11 support** required
|
||||
|
||||
### Accessibility Requirements
|
||||
- **WCAG 2.1 Level AA** compliance
|
||||
- **Keyboard Navigation**: All features accessible without mouse
|
||||
- **Screen Reader Support**: Proper ARIA labels and landmarks
|
||||
- **Color Contrast**: Minimum 4.5:1 for normal text, 3:1 for large text
|
||||
- **Focus Indicators**: Visible keyboard focus states
|
||||
|
||||
### Security Considerations
|
||||
- **No Sensitive Data in URLs**: Use POST requests for sensitive operations
|
||||
- **XSS Prevention**: Sanitize all user inputs
|
||||
- **CSRF Protection**: CSRF tokens for state-changing operations
|
||||
- **Rate Limiting**: Prevent brute force attacks
|
||||
- **Audit Trail**: Log all administrative actions
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow (Figma to Code)
|
||||
|
||||
### Phase 1: Design Analysis
|
||||
1. Fetch Figma designs using MCP
|
||||
2. Identify components and their variants
|
||||
3. Map Figma styles to Tailwind utilities
|
||||
4. Document responsive breakpoints from Figma frames
|
||||
|
||||
### Phase 2: Component Implementation
|
||||
1. Create TypeScript interfaces from Figma component properties
|
||||
2. Implement with shadcn/ui base components
|
||||
3. Style with Tailwind CSS (mobile-first)
|
||||
4. Add interactivity (state, events, validation)
|
||||
|
||||
### Phase 3: Integration
|
||||
1. Connect to Redux state
|
||||
2. Implement API calls with Axios
|
||||
3. Add error handling and loading states
|
||||
4. Implement accessibility features
|
||||
|
||||
### Phase 4: Validation
|
||||
1. Test across all breakpoints (320px - 1920px)
|
||||
2. Validate against Figma design (pixel-perfect)
|
||||
3. Test keyboard navigation and screen readers
|
||||
4. Performance audit with Lighthouse
|
||||
|
||||
---
|
||||
|
||||
## Glossary of Domain Terms
|
||||
|
||||
- **Module**: Independent service handling a specific QA function (AiCE, AiCH, etc.)
|
||||
- **Tenant**: Customer organization with isolated environment in multi-tenant system
|
||||
- **RAG**: Retrieval-Augmented Generation (AI + knowledge base)
|
||||
- **Prompt**: Instructions given to AI model to guide its response
|
||||
- **Token**: Unit of AI processing (roughly 0.75 words)
|
||||
- **Blue-Green Deployment**: Running old and new versions simultaneously, gradually shifting traffic
|
||||
- **Lifecycle Stage**: Current status of module (Development, Testing, Staging, Active, etc.)
|
||||
- **RBAC**: Role-Based Access Control (permissions assigned via roles)
|
||||
- **SSO**: Single Sign-On (use corporate credentials to access QAssure)
|
||||
- **QMS**: Quality Management System (external systems like Veeva Vault)
|
||||
- **Health Score**: Aggregated metric (0-100) indicating overall platform status
|
||||
- **Distributed Tracing**: Following a request through multiple microservices
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations (Not in Current Scope)
|
||||
|
||||
- **Mobile App**: Native iOS/Android apps (future phase)
|
||||
- **Offline Support**: Progressive Web App capabilities
|
||||
- **Advanced Analytics**: Machine learning for anomaly detection
|
||||
- **White-Labeling**: Custom branding per tenant
|
||||
- **API Marketplace**: Third-party integrations
|
||||
- **Workflow Automation**: Visual workflow builder
|
||||
|
||||
---
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!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" />
|
||||
<title>qassure-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4629
package-lock.json
generated
Normal file
4629
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "qassure-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
79
src/App.tsx
Normal file
79
src/App.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Tenants from "./pages/Tenants";
|
||||
import Users from "./pages/Users";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import Roles from "./pages/Roles";
|
||||
import Modules from "./pages/Modules";
|
||||
import AuditLogs from "./pages/AuditLogs";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tenants"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Tenants />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Users />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/roles"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Roles />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/modules"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Modules />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AuditLogs />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Catch-all route for 404 */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<NotFound />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
235
src/components/layout/Header.tsx
Normal file
235
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Search, Bell, ChevronDown, Menu, LogOut, User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { logoutAsync } from '@/store/authSlice';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
breadcrumbs?: Array<{ label: string; path?: string }>;
|
||||
currentPage: string;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get user initials for avatar
|
||||
const getUserInitials = (): string => {
|
||||
if (user?.first_name && user?.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
if (user?.email) {
|
||||
return user.email[0].toUpperCase();
|
||||
}
|
||||
return 'A';
|
||||
};
|
||||
|
||||
// Get user display name
|
||||
const getUserDisplayName = (): string => {
|
||||
if (user?.first_name && user?.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`;
|
||||
}
|
||||
return user?.email?.split('@')[0] || 'Admin';
|
||||
};
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
// Check if click is outside the dropdown container
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen) {
|
||||
// Use mousedown instead of click to avoid interfering with button clicks
|
||||
// Add listener in bubble phase (not capture) so button clicks fire first
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Close dropdown immediately
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
try {
|
||||
// Call logout API with Bearer token
|
||||
await dispatch(logoutAsync()).unwrap();
|
||||
// Clear state and redirect
|
||||
navigate('/', { replace: true });
|
||||
} catch (error: any) {
|
||||
// Even if API call fails, clear local state and redirect to login
|
||||
console.error('Logout error:', error);
|
||||
// Dispatch logout action to clear local state
|
||||
dispatch({ type: 'auth/logout' });
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white/80 backdrop-blur-sm border-b border-[rgba(0,0,0,0.08)] py-3 md:py-4 px-4 md:px-6 flex items-center justify-between z-50 sticky top-0">
|
||||
{/* Left Side - Menu Button (Mobile) + Breadcrumbs */}
|
||||
<div className="flex items-center gap-3 md:gap-2">
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden w-10 h-10 flex items-center justify-center rounded-md hover:bg-gray-100 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-[#0f1724]" />
|
||||
</button>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="flex items-center gap-1.5 md:gap-2">
|
||||
<span className="text-xs md:text-[13px] font-normal text-[#6b7280]">QAssure</span>
|
||||
<ChevronRight className="w-3 h-3 md:w-3.5 md:h-3.5 text-[#6b7280]" />
|
||||
<span className="text-xs md:text-[13px] font-medium text-[#0f1724] truncate max-w-[120px] md:max-w-none">
|
||||
{currentPage}
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Desktop User Dropdown */}
|
||||
<div className="hidden md:block relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="flex items-center gap-2.5 px-1.5 py-1.5 pr-1.5 bg-white border border-[rgba(0,0,0,0.08)] rounded-full hover:bg-gray-50 transition-colors cursor-pointer min-h-[44px]"
|
||||
aria-label="User menu"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<div className="w-7 h-7 bg-[#f1f5f9] rounded-[14px] flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-[#0f1724]">{getUserInitials()}</span>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-[#0f1724] pr-1">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn('w-3.5 h-3.5 text-[#0f1724] transition-transform', isDropdownOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-[52px] bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.08)] w-64 z-[100]"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* User Info Section */}
|
||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-[#f1f5f9] rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-[#0f1724]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{getUserDisplayName()}
|
||||
</p>
|
||||
<p className="text-xs text-[#6b7280] truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm font-medium text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>{isLoading ? 'Logging out...' : 'Logout'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile User Avatar */}
|
||||
<div className="md:hidden relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="w-8 h-8 min-h-[44px] min-w-[44px] bg-[#f1f5f9] rounded-full flex items-center justify-center cursor-pointer"
|
||||
aria-label="User menu"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<span className="text-xs font-medium text-[#0f1724]">{getUserInitials()}</span>
|
||||
</button>
|
||||
|
||||
{/* Mobile Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-[52px] bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.08)] w-64 z-[100]"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* User Info Section */}
|
||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-[#f1f5f9] rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-[#0f1724]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{getUserDisplayName()}
|
||||
</p>
|
||||
<p className="text-xs text-[#6b7280] truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm font-medium text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>{isLoading ? 'Logging out...' : 'Logout'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
68
src/components/layout/Layout.tsx
Normal file
68
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { PageHeader, type TabItem } from '@/components/shared';
|
||||
import type { ReactNode, ReactElement } from 'react';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
currentPage: string;
|
||||
pageHeader?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
tabs?: TabItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export const Layout = ({ children, currentPage, pageHeader }: LayoutProps): ReactElement => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
|
||||
|
||||
const toggleSidebar = (): void => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
|
||||
const closeSidebar = (): void => {
|
||||
setIsSidebarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-screen overflow-hidden bg-[#f6f9ff]">
|
||||
{/* Background */}
|
||||
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full md:h-[1006px] md:min-h-[812px] bg-[#f6f9ff] z-0" />
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className="absolute inset-0 flex gap-0 md:gap-3 p-0 md:p-3 max-w-full md:max-w-[1280px] lg:max-w-none min-h-screen md:min-h-[812px] mx-auto lg:mx-0 z-10">
|
||||
{/* Mobile Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={closeSidebar}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0 min-h-0 bg-white border-0 md:border border-[rgba(0,0,0,0.08)] rounded-none md:rounded-xl shadow-none md:shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] flex flex-col overflow-hidden w-full">
|
||||
{/* Top Header */}
|
||||
<Header currentPage={currentPage} onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 min-h-0 p-4 md:p-6 overflow-y-auto relative z-0">
|
||||
{/* Page Header */}
|
||||
{pageHeader && (
|
||||
<PageHeader
|
||||
title={pageHeader.title}
|
||||
description={pageHeader.description}
|
||||
tabs={pageHeader.tabs}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/components/layout/Sidebar.tsx
Normal file
155
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Users,
|
||||
Package,
|
||||
FileText,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
X,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MenuItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const platformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
||||
{ icon: Building2, label: 'Tenants', path: '/tenants' },
|
||||
{ icon: Users, label: 'User Management', path: '/users' },
|
||||
{ icon: Shield, label: 'Roles', path: '/roles' },
|
||||
{ icon: Package, label: 'Modules', path: '/modules' },
|
||||
];
|
||||
|
||||
const systemMenu: MenuItem[] = [
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
|
||||
{ icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
<div className="w-full md:w-[206px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="pb-1 px-3">
|
||||
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
// Close sidebar on mobile when navigating
|
||||
if (window.innerWidth < 768) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-md transition-colors min-h-[44px]',
|
||||
isActive
|
||||
? 'bg-[#112868] text-[#23dce1] shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]'
|
||||
: 'text-[#0f1724] hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="text-[13px] font-medium whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-full bg-white border-r border-[rgba(0,0,0,0.08)] z-50 flex flex-col gap-6 p-4 transition-transform duration-300 ease-in-out md:hidden',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={{ width: '280px' }}
|
||||
>
|
||||
{/* Mobile Header with Close Button */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-9 h-9 bg-[#112868] rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5 text-[#0f1724]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Platform Menu */}
|
||||
<MenuSection title="Platform" items={platformMenu} />
|
||||
|
||||
{/* System Menu */}
|
||||
<MenuSection title="System" items={systemMenu} />
|
||||
|
||||
{/* Support Center */}
|
||||
<div className="mt-auto">
|
||||
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors min-h-[44px]">
|
||||
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
|
||||
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-[17px] w-[240px] h-full flex-col gap-6 shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="w-[206px]">
|
||||
<div className="flex gap-3 items-center px-2">
|
||||
<div className="w-9 h-9 bg-[#112868] rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Menu */}
|
||||
<MenuSection title="Platform" items={platformMenu} />
|
||||
|
||||
{/* System Menu */}
|
||||
<MenuSection title="System" items={systemMenu} />
|
||||
|
||||
{/* Support Center */}
|
||||
<div className="mt-auto w-[206px]">
|
||||
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors">
|
||||
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
|
||||
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
100
src/components/shared/ActionDropdown.tsx
Normal file
100
src/components/shared/ActionDropdown.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { MoreVertical, Eye, Edit, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ActionDropdownProps {
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ActionDropdown = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}: ActionDropdownProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAction = (action: () => void | undefined) => {
|
||||
if (action) {
|
||||
action();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors',
|
||||
isOpen
|
||||
? 'bg-[#084cc8] text-white'
|
||||
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
||||
)}
|
||||
aria-label="Actions"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-8 bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] w-[76px] z-50">
|
||||
<div className="flex flex-col py-1.5">
|
||||
{onView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onView)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View</span>
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onEdit)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onDelete)}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
163
src/components/shared/DataTable.tsx
Normal file
163
src/components/shared/DataTable.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
mobileLabel?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
mobileCardRenderer?: (item: T) => ReactNode;
|
||||
emptyMessage?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const DataTable = <T,>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
mobileCardRenderer,
|
||||
emptyMessage = 'No data found',
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}: DataTableProps<T>): ReactElement => {
|
||||
// Loading State
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#6b7280]">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error State
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty State - show table structure with empty message
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Table Empty State */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2]`}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-5 py-8 text-center text-sm text-[#6b7280]">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Mobile Empty State */}
|
||||
<div className="md:hidden p-8 text-center">
|
||||
<p className="text-sm text-[#6b7280]">{emptyMessage}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2]`}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
return (
|
||||
<td key={column.key} className={`px-5 py-4 ${alignClass}`}>
|
||||
{column.render ? column.render(item) : String((item as any)[column.key])}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View */}
|
||||
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
|
||||
{mobileCardRenderer
|
||||
? data.map((item) => <div key={keyExtractor(item)}>{mobileCardRenderer(item)}</div>)
|
||||
: data.map((item) => (
|
||||
<div key={keyExtractor(item)} className="p-4">
|
||||
{columns.map((column) => (
|
||||
<div key={column.key} className="mb-3 last:mb-0">
|
||||
<span className="text-xs text-[#9aa6b2] mb-1 block">
|
||||
{column.mobileLabel || column.label}:
|
||||
</span>
|
||||
<div className="text-sm text-[#0f1724]">
|
||||
{column.render ? column.render(item) : String((item as any)[column.key])}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
130
src/components/shared/DeleteConfirmationModal.tsx
Normal file
130
src/components/shared/DeleteConfirmationModal.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
import { PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||
|
||||
interface DeleteConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
title: string;
|
||||
message: string;
|
||||
itemName?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DeleteConfirmationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
itemName,
|
||||
isLoading = false,
|
||||
}: DeleteConfirmationModalProps): ReactElement | null => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close modal
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.6)] backdrop-blur-md p-4">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="bg-white rounded-xl shadow-[0px_20px_25px_-5px_rgba(0,0,0,0.1),0px_10px_10px_-5px_rgba(0,0,0,0.04)] w-full max-w-[400px] z-[201]"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between pb-4 pt-5 px-5 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[rgba(239,68,68,0.1)] rounded-full flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-[#ef4444]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold text-[#0e1b2a]">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close modal"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-[#0e1b2a]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-[#6b7280] leading-relaxed">
|
||||
{message}
|
||||
{itemName && (
|
||||
<span className="font-medium text-[#0e1b2a]"> "{itemName}"</span>
|
||||
)}
|
||||
? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 px-5 pb-5 border-t border-[rgba(0,0,0,0.08)]">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm bg-[#ef4444] hover:bg-[#dc2626]"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
184
src/components/shared/EditRoleModal.tsx
Normal file
184
src/components/shared/EditRoleModal.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||
import type { Role, UpdateRoleRequest } from '@/types/role';
|
||||
|
||||
// Validation schema
|
||||
const editRoleSchema = z.object({
|
||||
name: z.string().min(1, 'Role name is required'),
|
||||
code: z.string().min(1, 'Role code is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
scope: z.enum(['platform', 'tenant', 'module'], {
|
||||
message: 'Scope is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type EditRoleFormData = z.infer<typeof editRoleSchema>;
|
||||
|
||||
interface EditRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
roleId: string | null;
|
||||
onLoadRole: (id: string) => Promise<Role>;
|
||||
onSubmit: (id: string, data: UpdateRoleRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: 'platform', label: 'Platform' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'module', label: 'Module' },
|
||||
];
|
||||
|
||||
export const EditRoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
roleId,
|
||||
onLoadRole,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: EditRoleModalProps): ReactElement | null => {
|
||||
const [isLoadingRole, setIsLoadingRole] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<EditRoleFormData>({
|
||||
resolver: zodResolver(editRoleSchema),
|
||||
});
|
||||
|
||||
const scopeValue = watch('scope');
|
||||
|
||||
// Load role data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && roleId) {
|
||||
const loadRole = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoadingRole(true);
|
||||
setLoadError(null);
|
||||
const role = await onLoadRole(roleId);
|
||||
reset({
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || '',
|
||||
scope: role.scope,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setLoadError(err?.response?.data?.error?.message || 'Failed to load role details');
|
||||
} finally {
|
||||
setIsLoadingRole(false);
|
||||
}
|
||||
};
|
||||
loadRole();
|
||||
} else {
|
||||
reset({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
scope: 'platform',
|
||||
});
|
||||
setLoadError(null);
|
||||
}
|
||||
}, [isOpen, roleId, onLoadRole, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: EditRoleFormData): Promise<void> => {
|
||||
if (roleId) {
|
||||
await onSubmit(roleId, data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Edit Role"
|
||||
description="Update role by setting permissions and role type."
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading || isLoadingRole}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading || isLoadingRole}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Role'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isLoadingRole && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{loadError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingRole && (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
{/* Role Name and Role Code Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="Role Name"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Role Code"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.code?.message}
|
||||
{...register('code')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
label="Description"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.description?.message}
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Scope */}
|
||||
<FormSelect
|
||||
label="Scope"
|
||||
required
|
||||
placeholder="Select Scope"
|
||||
options={scopeOptions}
|
||||
value={scopeValue}
|
||||
onValueChange={(value) => setValue('scope', value as 'platform' | 'tenant' | 'module')}
|
||||
error={errors.scope?.message}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
202
src/components/shared/EditTenantModal.tsx
Normal file
202
src/components/shared/EditTenantModal.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
|
||||
// Validation schema
|
||||
const editTenantSchema = z.object({
|
||||
name: z.string().min(1, 'Tenant name is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
status: z.enum(['active', 'suspended', 'blocked'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
timezone: z.string().min(1, 'Timezone is required'),
|
||||
});
|
||||
|
||||
type EditTenantFormData = z.infer<typeof editTenantSchema>;
|
||||
|
||||
interface EditTenantModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tenantId: string | null;
|
||||
onLoadTenant: (id: string) => Promise<Tenant>;
|
||||
onSubmit: (id: string, data: EditTenantFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'America/New_York', label: 'America/New_York (EST)' },
|
||||
{ value: 'America/Chicago', label: 'America/Chicago (CST)' },
|
||||
{ value: 'America/Denver', label: 'America/Denver (MST)' },
|
||||
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'Europe/London', label: 'Europe/London (GMT)' },
|
||||
{ value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
|
||||
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
|
||||
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT)' },
|
||||
];
|
||||
|
||||
export const EditTenantModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
tenantId,
|
||||
onLoadTenant,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: EditTenantModalProps): ReactElement | null => {
|
||||
const [isLoadingTenant, setIsLoadingTenant] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<EditTenantFormData>({
|
||||
resolver: zodResolver(editTenantSchema),
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
const timezoneValue = watch('timezone');
|
||||
|
||||
// Load tenant data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && tenantId) {
|
||||
const loadTenant = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoadingTenant(true);
|
||||
setLoadError(null);
|
||||
const tenant = await onLoadTenant(tenantId);
|
||||
reset({
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
status: tenant.status,
|
||||
timezone: tenant.settings?.timezone || 'America/New_York',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details');
|
||||
} finally {
|
||||
setIsLoadingTenant(false);
|
||||
}
|
||||
};
|
||||
loadTenant();
|
||||
} else {
|
||||
reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
status: 'active',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
setLoadError(null);
|
||||
}
|
||||
}, [isOpen, tenantId, onLoadTenant, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: EditTenantFormData): Promise<void> => {
|
||||
if (tenantId) {
|
||||
await onSubmit(tenantId, data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Edit Tenant"
|
||||
description="Update tenant information"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading || isLoadingTenant}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading || isLoadingTenant}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Tenant'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
{isLoadingTenant && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{loadError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingTenant && (
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Tenant Name */}
|
||||
<FormField
|
||||
label="Tenant Name"
|
||||
required
|
||||
placeholder="Enter tenant name"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
{/* Slug */}
|
||||
<FormField
|
||||
label="Slug"
|
||||
required
|
||||
placeholder="Enter slug name"
|
||||
error={errors.slug?.message}
|
||||
{...register('slug')}
|
||||
/>
|
||||
|
||||
{/* Status and Timezone Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Timezone"
|
||||
required
|
||||
placeholder="Select Timezone"
|
||||
options={timezoneOptions}
|
||||
value={timezoneValue}
|
||||
onValueChange={(value) => setValue('timezone', value)}
|
||||
error={errors.timezone?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
306
src/components/shared/EditUserModal.tsx
Normal file
306
src/components/shared/EditUserModal.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
FormSelect,
|
||||
PaginatedSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/components/shared';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { roleService } from '@/services/role-service';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
// Validation schema
|
||||
const editUserSchema = z.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
status: z.enum(['active', 'suspended', 'blocked'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
tenant_id: z.string().min(1, 'Tenant is required'),
|
||||
role_id: z.string().min(1, 'Role is required'),
|
||||
});
|
||||
|
||||
type EditUserFormData = z.infer<typeof editUserSchema>;
|
||||
|
||||
interface EditUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId: string | null;
|
||||
onLoadUser: (id: string) => Promise<User>;
|
||||
onSubmit: (id: string, data: EditUserFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
export const EditUserModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userId,
|
||||
onLoadUser,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: EditUserModalProps): ReactElement | null => {
|
||||
const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>('');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<EditUserFormData>({
|
||||
resolver: zodResolver(editUserSchema),
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
const tenantIdValue = watch('tenant_id');
|
||||
const roleIdValue = watch('role_id');
|
||||
|
||||
// Load tenants for dropdown - ensure selected tenant is included
|
||||
const loadTenants = async (page: number, limit: number) => {
|
||||
const response = await tenantService.getAll(page, limit);
|
||||
let options = response.data.map((tenant) => ({
|
||||
value: tenant.id,
|
||||
label: tenant.name,
|
||||
}));
|
||||
|
||||
// If we have a selected tenant ID and it's not in the current options, fetch it specifically
|
||||
if (selectedTenantId && page === 1 && !options.find((opt) => opt.value === selectedTenantId)) {
|
||||
try {
|
||||
const tenantResponse = await tenantService.getById(selectedTenantId);
|
||||
if (tenantResponse.success) {
|
||||
// Prepend the selected tenant to the options
|
||||
options = [
|
||||
{
|
||||
value: tenantResponse.data.id,
|
||||
label: tenantResponse.data.name,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected tenant:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
// Load roles for dropdown - ensure selected role is included
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
const response = await roleService.getAll(page, limit);
|
||||
let options = response.data.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
}));
|
||||
|
||||
// If we have a selected role ID and it's not in the current options, fetch it specifically
|
||||
if (selectedRoleId && page === 1 && !options.find((opt) => opt.value === selectedRoleId)) {
|
||||
try {
|
||||
const roleResponse = await roleService.getById(selectedRoleId);
|
||||
if (roleResponse.success) {
|
||||
// Prepend the selected role to the options
|
||||
options = [
|
||||
{
|
||||
value: roleResponse.data.id,
|
||||
label: roleResponse.data.name,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected role:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
// Load user data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
const loadUser = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoadingUser(true);
|
||||
setLoadError(null);
|
||||
const user = await onLoadUser(userId);
|
||||
|
||||
// Store selected IDs for dropdown pre-loading
|
||||
const tenantId = user.tenant_id || '';
|
||||
const roleId = user.role_id || '';
|
||||
setSelectedTenantId(tenantId);
|
||||
setSelectedRoleId(roleId);
|
||||
|
||||
reset({
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
status: user.status,
|
||||
tenant_id: tenantId,
|
||||
role_id: roleId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
} else {
|
||||
setSelectedTenantId('');
|
||||
setSelectedRoleId('');
|
||||
reset({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
tenant_id: '',
|
||||
role_id: '',
|
||||
});
|
||||
setLoadError(null);
|
||||
}
|
||||
}, [isOpen, userId, onLoadUser, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: EditUserFormData): Promise<void> => {
|
||||
if (userId) {
|
||||
await onSubmit(userId, data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Edit User"
|
||||
description="Update user information"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading || isLoadingUser}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading || isLoadingUser}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update User'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
{isLoadingUser && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{loadError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingUser && (
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Email */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter email address"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
{/* First Name and Last Name Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="First Name"
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
error={errors.first_name?.message}
|
||||
{...register('first_name')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Last Name"
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
error={errors.last_name?.message}
|
||||
{...register('last_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Tenant"
|
||||
required
|
||||
placeholder="Select Tenant"
|
||||
value={tenantIdValue || ''}
|
||||
onValueChange={(value) => setValue('tenant_id', value)}
|
||||
onLoadOptions={loadTenants}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
|
||||
<PaginatedSelect
|
||||
label="Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue || ''}
|
||||
onValueChange={(value) => setValue('role_id', value)}
|
||||
onLoadOptions={loadRoles}
|
||||
error={errors.role_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
174
src/components/shared/FilterDropdown.tsx
Normal file
174
src/components/shared/FilterDropdown.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FilterOption {
|
||||
value: string | string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
value: string | string[] | null;
|
||||
onChange: (value: string | string[] | null) => void;
|
||||
placeholder?: string;
|
||||
showIcon?: boolean;
|
||||
icon?: ReactElement;
|
||||
}
|
||||
|
||||
export const FilterDropdown = ({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'All',
|
||||
showIcon = false,
|
||||
icon,
|
||||
}: FilterDropdownProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left: string;
|
||||
width: string;
|
||||
}>({ left: '0', width: '0' });
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isDropdownClick = target.closest('[data-filter-dropdown="true"]');
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!isDropdownClick &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const dropdownHeight = Math.min(options.length * 40 + 12, 240);
|
||||
|
||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
const left = rect.left;
|
||||
const width = rect.width;
|
||||
|
||||
if (shouldOpenUp) {
|
||||
const bottom = window.innerHeight - rect.top;
|
||||
setDropdownStyle({
|
||||
bottom: `${bottom}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
} else {
|
||||
const top = rect.bottom;
|
||||
setDropdownStyle({
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
// Helper to compare values (handles both string and array)
|
||||
const compareValues = (val1: string | string[], val2: string | string[]): boolean => {
|
||||
if (Array.isArray(val1) && Array.isArray(val2)) {
|
||||
return val1.length === val2.length && val1.every((v, i) => v === val2[i]);
|
||||
}
|
||||
return val1 === val2;
|
||||
};
|
||||
|
||||
const selectedOption = value
|
||||
? options.find((opt) => compareValues(opt.value, value))
|
||||
: null;
|
||||
const displayText = selectedOption ? selectedOption.label : placeholder;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-[#475569] hover:bg-gray-50 transition-colors min-h-[44px]"
|
||||
>
|
||||
{showIcon && icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
<span>{label}</span>
|
||||
<span className="text-[#94a3b8] text-sm">{displayText}</span>
|
||||
<ChevronDown
|
||||
className={cn('w-3.5 h-3.5 text-[#94a3b8] transition-transform', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
buttonRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
data-filter-dropdown="true"
|
||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
<ul className="py-1.5">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
!value && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
</li>
|
||||
{options.map((option, index) => {
|
||||
const optionKey = Array.isArray(option.value)
|
||||
? option.value.join(':')
|
||||
: option.value;
|
||||
const isSelected = value ? compareValues(option.value, value) : false;
|
||||
return (
|
||||
<li key={optionKey || index}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
isSelected && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
src/components/shared/FormField.tsx
Normal file
59
src/components/shared/FormField.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import type { ReactElement, InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const FormField = ({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: FormFieldProps): ReactElement => {
|
||||
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={fieldId}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
src/components/shared/FormSelect.tsx
Normal file
200
src/components/shared/FormSelect.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement, SelectHTMLAttributes } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FormSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export const FormSelect = ({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
helperText,
|
||||
options,
|
||||
placeholder = 'Select Item',
|
||||
className,
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
...props
|
||||
}: FormSelectProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [selectedValue, setSelectedValue] = useState<string>(value as string || '');
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; left: string; width: string }>({ left: '0', width: '0' });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
dropdownMenuRef.current &&
|
||||
!dropdownMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && buttonRef.current) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// Calculate position when dropdown opens
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const dropdownHeight = 240; // max-h-60 = 240px
|
||||
|
||||
// Determine if should open upward or downward
|
||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
|
||||
// Calculate dropdown position
|
||||
const left = rect.left;
|
||||
const width = rect.width;
|
||||
|
||||
if (shouldOpenUp) {
|
||||
// Position above the button
|
||||
const bottom = window.innerHeight - rect.top;
|
||||
setDropdownStyle({
|
||||
bottom: `${bottom}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
} else {
|
||||
// Position below the button
|
||||
const top = rect.bottom;
|
||||
setDropdownStyle({
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
setSelectedValue(value as string);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
setSelectedValue(optionValue);
|
||||
if (onValueChange) {
|
||||
onValueChange(optionValue);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
id={fieldId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'flex items-center justify-between',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
selectedValue ? 'text-[#0e1b2a]' : 'text-[#9aa6b2]',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span>{selectedOption ? selectedOption.label : placeholder}</span>
|
||||
<ChevronDown
|
||||
className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && buttonRef.current && createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
data-dropdown-menu="true"
|
||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
<ul role="listbox" className="py-1.5">
|
||||
{options.map((option) => (
|
||||
<li key={option.value} role="option" aria-selected={selectedValue === option.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
selectedValue === option.value && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
value={selectedValue}
|
||||
onChange={(e) => handleSelect(e.target.value)}
|
||||
className="sr-only"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
{...props}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
130
src/components/shared/Modal.tsx
Normal file
130
src/components/shared/Modal.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
preventCloseOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: 'max-w-[400px]',
|
||||
md: 'max-w-[500px]',
|
||||
lg: 'max-w-[600px]',
|
||||
xl: 'max-w-[800px]',
|
||||
'2xl': 'max-w-[1000px]',
|
||||
};
|
||||
|
||||
export const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = 'md',
|
||||
className,
|
||||
showCloseButton = true,
|
||||
preventCloseOnClickOutside = false,
|
||||
}: ModalProps): ReactElement | null => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close modal
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (preventCloseOnClickOutside) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if click is on dropdown menu (rendered via portal)
|
||||
const isDropdownClick = target.closest('[data-dropdown-menu="true"]');
|
||||
|
||||
// Only close if click is outside modal AND not on dropdown
|
||||
if (modalRef.current && !modalRef.current.contains(target) && !isDropdownClick) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose, preventCloseOnClickOutside]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !preventCloseOnClickOutside) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose, preventCloseOnClickOutside]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.6)] backdrop-blur-md p-4">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-[0px_20px_25px_-5px_rgba(0,0,0,0.1),0px_10px_10px_-5px_rgba(0,0,0,0.04)] w-full max-h-[90vh] flex flex-col z-[201]',
|
||||
maxWidthClasses[maxWidth],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between pb-4 pt-5 px-5 border-b border-[rgba(0,0,0,0.08)] shrink-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold text-[#0e1b2a]">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm font-normal text-[#9aa6b2]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-5 h-5 text-[#0e1b2a]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Body - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">{children}</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 pt-4 px-5 pb-5 border-t border-[rgba(0,0,0,0.08)] shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
143
src/components/shared/NewRoleModal.tsx
Normal file
143
src/components/shared/NewRoleModal.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||
import type { CreateRoleRequest } from '@/types/role';
|
||||
|
||||
// Validation schema
|
||||
const newRoleSchema = z.object({
|
||||
name: z.string().min(1, 'Role name is required'),
|
||||
code: z.string().min(1, 'Role code is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
scope: z.enum(['platform', 'tenant', 'module'], {
|
||||
message: 'Scope is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type NewRoleFormData = z.infer<typeof newRoleSchema>;
|
||||
|
||||
interface NewRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateRoleRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: 'platform', label: 'Platform' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'module', label: 'Module' },
|
||||
];
|
||||
|
||||
export const NewRoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewRoleModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewRoleFormData>({
|
||||
resolver: zodResolver(newRoleSchema),
|
||||
defaultValues: {
|
||||
scope: 'platform',
|
||||
},
|
||||
});
|
||||
|
||||
const scopeValue = watch('scope');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
scope: 'platform',
|
||||
});
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: NewRoleFormData): Promise<void> => {
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create Role"
|
||||
description="Define a new role by setting permissions and role type."
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Role'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
{/* Role Name and Role Code Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="Role Name"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Role Code"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.code?.message}
|
||||
{...register('code')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
label="Description"
|
||||
required
|
||||
placeholder="Enter Text Here"
|
||||
error={errors.description?.message}
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Scope */}
|
||||
<FormSelect
|
||||
label="Scope"
|
||||
required
|
||||
placeholder="Select Scope"
|
||||
options={scopeOptions}
|
||||
value={scopeValue}
|
||||
onValueChange={(value) => setValue('scope', value as 'platform' | 'tenant' | 'module')}
|
||||
error={errors.scope?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
161
src/components/shared/NewTenantModal.tsx
Normal file
161
src/components/shared/NewTenantModal.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||
|
||||
// Validation schema
|
||||
const newTenantSchema = z.object({
|
||||
name: z.string().min(1, 'Tenant name is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
status: z.enum(['active', 'suspended', 'blocked'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
timezone: z.string().min(1, 'Timezone is required'),
|
||||
});
|
||||
|
||||
type NewTenantFormData = z.infer<typeof newTenantSchema>;
|
||||
|
||||
interface NewTenantModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: NewTenantFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'America/New_York', label: 'America/New_York (EST)' },
|
||||
{ value: 'America/Chicago', label: 'America/Chicago (CST)' },
|
||||
{ value: 'America/Denver', label: 'America/Denver (MST)' },
|
||||
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'Europe/London', label: 'Europe/London (GMT)' },
|
||||
{ value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
|
||||
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
|
||||
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT)' },
|
||||
];
|
||||
|
||||
export const NewTenantModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewTenantModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewTenantFormData>({
|
||||
resolver: zodResolver(newTenantSchema),
|
||||
defaultValues: {
|
||||
status: 'active',
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
const timezoneValue = watch('timezone');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
status: 'active',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: NewTenantFormData): Promise<void> => {
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New Tenant"
|
||||
description="Add a new organization to the platform"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Tenant'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Tenant Name */}
|
||||
<FormField
|
||||
label="Tenant Name"
|
||||
required
|
||||
placeholder="Enter tenant name"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
{/* Slug */}
|
||||
<FormField
|
||||
label="Slug"
|
||||
required
|
||||
placeholder="Enter slug name"
|
||||
error={errors.slug?.message}
|
||||
{...register('slug')}
|
||||
/>
|
||||
|
||||
{/* Status and Timezone Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Timezone"
|
||||
required
|
||||
placeholder="Select Timezone"
|
||||
options={timezoneOptions}
|
||||
value={timezoneValue}
|
||||
onValueChange={(value) => setValue('timezone', value)}
|
||||
error={errors.timezone?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
245
src/components/shared/NewUserModal.tsx
Normal file
245
src/components/shared/NewUserModal.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
FormSelect,
|
||||
PaginatedSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/components/shared';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { roleService } from '@/services/role-service';
|
||||
|
||||
// Validation schema
|
||||
const newUserSchema = z
|
||||
.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Confirm password is required'),
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
status: z.enum(['active', 'suspended', 'blocked'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
auth_provider: z.enum(['local'], {
|
||||
message: 'Auth provider is required',
|
||||
}),
|
||||
tenant_id: z.string().min(1, 'Tenant is required'),
|
||||
role_id: z.string().min(1, 'Role is required'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type NewUserFormData = z.infer<typeof newUserSchema>;
|
||||
|
||||
interface NewUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Omit<NewUserFormData, 'confirmPassword'>) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
export const NewUserModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewUserModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewUserFormData>({
|
||||
resolver: zodResolver(newUserSchema),
|
||||
defaultValues: {
|
||||
status: 'active',
|
||||
auth_provider: 'local',
|
||||
tenant_id: '',
|
||||
role_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
const tenantIdValue = watch('tenant_id');
|
||||
const roleIdValue = watch('role_id');
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
auth_provider: 'local',
|
||||
tenant_id: '',
|
||||
role_id: '',
|
||||
});
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
// Load tenants for dropdown
|
||||
const loadTenants = async (page: number, limit: number) => {
|
||||
const response = await tenantService.getAll(page, limit);
|
||||
return {
|
||||
options: response.data.map((tenant) => ({
|
||||
value: tenant.id,
|
||||
label: tenant.name,
|
||||
})),
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
// Load roles for dropdown
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
const response = await roleService.getAll(page, limit);
|
||||
return {
|
||||
options: response.data.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
})),
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
|
||||
const { confirmPassword, ...submitData } = data;
|
||||
await onSubmit(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New User"
|
||||
description="Invite a new user to this tenant"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create User'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Email */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter email address"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
{/* First Name and Last Name Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="First Name"
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
error={errors.first_name?.message}
|
||||
{...register('first_name')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Last Name"
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
error={errors.last_name?.message}
|
||||
{...register('last_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password and Confirm Password Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Tenant"
|
||||
required
|
||||
placeholder="Select Tenant"
|
||||
value={tenantIdValue}
|
||||
onValueChange={(value) => setValue('tenant_id', value)}
|
||||
onLoadOptions={loadTenants}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
|
||||
<PaginatedSelect
|
||||
label="Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue}
|
||||
onValueChange={(value) => setValue('role_id', value)}
|
||||
onLoadOptions={loadRoles}
|
||||
error={errors.role_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
80
src/components/shared/PageHeader.tsx
Normal file
80
src/components/shared/PageHeader.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TabItem {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
tabs?: TabItem[];
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
{ label: 'Overview', path: '/dashboard' },
|
||||
{ label: 'Tenants', path: '/tenants' },
|
||||
{ label: 'Users', path: '/users' },
|
||||
{ label: 'Roles', path: '/roles' },
|
||||
{ label: 'Modules', path: '/modules' },
|
||||
{ label: 'Audit Logs', path: '/audit-logs' },
|
||||
];
|
||||
|
||||
export const PageHeader = ({
|
||||
title,
|
||||
description,
|
||||
tabs = defaultTabs,
|
||||
}: PageHeaderProps): ReactElement => {
|
||||
const location = useLocation();
|
||||
|
||||
const isActiveTab = (path: string): boolean => {
|
||||
// Exact match for dashboard
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/dashboard';
|
||||
}
|
||||
// For other paths, check if current path starts with the tab path
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 md:gap-6 mb-6">
|
||||
{/* Title and Description */}
|
||||
<div className="flex flex-col gap-1 max-w-full md:max-w-[434px]">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] tracking-[-0.48px]">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-sm md:text-base font-normal text-[#6b7280]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = isActiveTab(tab.path);
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={cn(
|
||||
'flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors min-w-[76px]',
|
||||
isActive
|
||||
? 'bg-[#112868] text-white'
|
||||
: 'bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] text-[#0f1724] hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
284
src/components/shared/PaginatedSelect.tsx
Normal file
284
src/components/shared/PaginatedSelect.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PaginatedSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PaginatedSelectProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onLoadOptions: (page: number, limit: number) => Promise<{
|
||||
options: PaginatedSelectOption[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}>;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const PaginatedSelect = ({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
helperText,
|
||||
placeholder = 'Select Item',
|
||||
value,
|
||||
onValueChange,
|
||||
onLoadOptions,
|
||||
className,
|
||||
id,
|
||||
}: PaginatedSelectProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [options, setOptions] = useState<PaginatedSelectOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLUListElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left: string;
|
||||
width: string;
|
||||
}>({ left: '0', width: '0' });
|
||||
|
||||
// Load initial options
|
||||
const loadOptions = useCallback(
|
||||
async (page: number = 1, append: boolean = false) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsLoadingMore(true);
|
||||
}
|
||||
|
||||
const result = await onLoadOptions(page, pagination.limit);
|
||||
|
||||
if (append) {
|
||||
setOptions((prev) => [...prev, ...result.options]);
|
||||
} else {
|
||||
setOptions(result.options);
|
||||
}
|
||||
|
||||
setPagination(result.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error loading options:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[onLoadOptions, pagination.limit]
|
||||
);
|
||||
|
||||
// Load options when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (options.length === 0) {
|
||||
loadOptions(1, false);
|
||||
}
|
||||
} else {
|
||||
// Reset pagination when dropdown closes (but keep options for faster reopening)
|
||||
// Only reset if we want fresh data each time
|
||||
// setOptions([]);
|
||||
// setPagination({ page: 1, limit: 20, total: 0, totalPages: 1, hasMore: false });
|
||||
}
|
||||
}, [isOpen, options.length, loadOptions]);
|
||||
|
||||
// Handle scroll for infinite loading
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer || !isOpen) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isNearBottom && pagination.hasMore && !isLoadingMore && !isLoading) {
|
||||
loadOptions(pagination.page + 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(target) &&
|
||||
dropdownMenuRef.current &&
|
||||
!dropdownMenuRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && buttonRef.current) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const dropdownHeight = Math.min(240, 240);
|
||||
|
||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
|
||||
if (shouldOpenUp) {
|
||||
setDropdownStyle({
|
||||
bottom: `${window.innerHeight - rect.top + 5}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
});
|
||||
} else {
|
||||
setDropdownStyle({
|
||||
top: `${rect.bottom + 5}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
id={fieldId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'flex items-center justify-between',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
value ? 'text-[#0e1b2a]' : 'text-[#9aa6b2]',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span>{selectedOption ? selectedOption.label : placeholder}</span>
|
||||
<ChevronDown
|
||||
className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
buttonRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-hidden flex flex-col"
|
||||
style={dropdownStyle}
|
||||
data-dropdown-menu="true"
|
||||
>
|
||||
{isLoading && options.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul
|
||||
ref={scrollContainerRef}
|
||||
role="listbox"
|
||||
className="py-1.5 overflow-y-auto flex-1"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<li key={option.value} role="option" aria-selected={value === option.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
value === option.value && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<li className="flex items-center justify-center py-2">
|
||||
<Loader2 className="w-4 h-4 text-[#112868] animate-spin" />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
src/components/shared/Pagination.tsx
Normal file
200
src/components/shared/Pagination.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LimitOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
limit: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
limitOptions?: LimitOption[];
|
||||
}
|
||||
|
||||
const defaultLimitOptions = [
|
||||
{ value: '5', label: '5 per page' },
|
||||
{ value: '10', label: '10 per page' },
|
||||
{ value: '20', label: '20 per page' },
|
||||
{ value: '50', label: '50 per page' },
|
||||
{ value: '100', label: '100 per page' },
|
||||
];
|
||||
|
||||
export const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
limit,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
limitOptions = defaultLimitOptions,
|
||||
}: PaginationProps): ReactElement => {
|
||||
const [isLimitOpen, setIsLimitOpen] = useState<boolean>(false);
|
||||
const limitDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const limitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [limitDropdownStyle, setLimitDropdownStyle] = useState<{
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left: string;
|
||||
width: string;
|
||||
}>({ left: '0', width: '0' });
|
||||
|
||||
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * limit + 1;
|
||||
const endItem = Math.min(currentPage * limit, totalItems);
|
||||
|
||||
const handlePrevious = (): void => {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (currentPage < totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle limit dropdown click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isDropdownClick = target.closest('[data-limit-dropdown="true"]');
|
||||
if (
|
||||
limitDropdownRef.current &&
|
||||
!limitDropdownRef.current.contains(target) &&
|
||||
!isDropdownClick &&
|
||||
limitButtonRef.current &&
|
||||
!limitButtonRef.current.contains(target)
|
||||
) {
|
||||
setIsLimitOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLimitOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
if (limitButtonRef.current) {
|
||||
const rect = limitButtonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const dropdownHeight = 200;
|
||||
|
||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
const left = rect.left;
|
||||
const width = rect.width;
|
||||
|
||||
if (shouldOpenUp) {
|
||||
const bottom = window.innerHeight - rect.top;
|
||||
setLimitDropdownStyle({
|
||||
bottom: `${bottom}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
} else {
|
||||
const top = rect.bottom;
|
||||
setLimitDropdownStyle({
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isLimitOpen]);
|
||||
|
||||
const selectedLimitOption = limitOptions.find((opt) => Number(opt.value) === limit);
|
||||
|
||||
return (
|
||||
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
{/* Items Info and Limit Selector */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
Showing {startItem} to {endItem} of {totalItems} {totalItems === 1 ? 'item' : 'items'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative" ref={limitDropdownRef}>
|
||||
<span className="text-xs text-[#6b7280]">Show:</span>
|
||||
<div className="w-[120px] relative">
|
||||
<button
|
||||
ref={limitButtonRef}
|
||||
type="button"
|
||||
onClick={() => setIsLimitOpen(!isLimitOpen)}
|
||||
className="h-8 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0e1b2a] flex items-center justify-between hover:bg-gray-50 transition-colors min-h-[44px]"
|
||||
>
|
||||
<span>{selectedLimitOption ? selectedLimitOption.label : `${limit} per page`}</span>
|
||||
<ChevronDown
|
||||
className={cn('w-3.5 h-3.5 transition-transform', isLimitOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
{isLimitOpen &&
|
||||
limitButtonRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
data-limit-dropdown="true"
|
||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
|
||||
style={limitDropdownStyle}
|
||||
>
|
||||
<ul className="py-1.5">
|
||||
{limitOptions.map((option) => (
|
||||
<li key={option.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onLimitChange(Number(option.value));
|
||||
setIsLimitOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-xs text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
||||
Number(option.value) === limit && 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-white border border-[rgba(0,0,0,0.08)] rounded text-xs text-[#0f1724] hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-[#6b7280] px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-[#112868] text-white rounded text-xs hover:bg-[#0e1f5a] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
src/components/shared/PrimaryButton.tsx
Normal file
51
src/components/shared/PrimaryButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const primaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10',
|
||||
small: 'h-8',
|
||||
large: 'h-12',
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-[#112868] text-[#23dce1] hover:bg-[#23dce1] hover:text-[#112868]',
|
||||
disabled: 'bg-[#112868] text-[#23dce1] opacity-50',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface PrimaryButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof primaryButtonVariants> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PrimaryButton = ({
|
||||
children,
|
||||
size,
|
||||
variant,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: PrimaryButtonProps): ReactElement => {
|
||||
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(primaryButtonVariants({ size, variant: buttonVariant }), className)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
44
src/components/shared/SecondaryButton.tsx
Normal file
44
src/components/shared/SecondaryButton.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const secondaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[#23dce1] text-[#112868] hover:bg-[#112868] hover:text-[#23dce1]',
|
||||
disabled: 'bg-[#23dce1] text-[#112868] opacity-50',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SecondaryButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof secondaryButtonVariants> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SecondaryButton = ({
|
||||
children,
|
||||
variant,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: SecondaryButtonProps): ReactElement => {
|
||||
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(secondaryButtonVariants({ variant: buttonVariant }), className)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
45
src/components/shared/StatusBadge.tsx
Normal file
45
src/components/shared/StatusBadge.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const statusBadgeVariants = cva(
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
success: 'bg-[rgba(16,185,129,0.1)] text-[#059669]',
|
||||
failure: 'bg-[rgba(239,68,68,0.1)] text-[#ef4444]',
|
||||
info: 'bg-[#e7f8f2] text-[#084cc8]',
|
||||
process: 'bg-[rgba(245,158,11,0.1)] text-[#d97706]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'success',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface StatusBadgeProps extends VariantProps<typeof statusBadgeVariants> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusDotColors = {
|
||||
success: 'bg-[#059669]',
|
||||
failure: 'bg-[#ef4444]',
|
||||
info: 'bg-[#084cc8]',
|
||||
process: 'bg-[#d97706]',
|
||||
};
|
||||
|
||||
export const StatusBadge = ({
|
||||
children,
|
||||
variant = 'success',
|
||||
className,
|
||||
}: StatusBadgeProps): ReactElement => {
|
||||
return (
|
||||
<div className={cn(statusBadgeVariants({ variant }), className)}>
|
||||
<div className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/shared/ThemeButton.tsx
Normal file
45
src/components/shared/ThemeButton.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const themeButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[#084cc8] text-white hover:bg-[#23dce1] hover:text-[#084cc8]',
|
||||
disabled: 'bg-[#084cc8] text-white opacity-50',
|
||||
light: 'bg-[#f5f7fa] text-[#0e1b2a] border border-[rgba(0,0,0,0.08)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ThemeButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof themeButtonVariants> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeButton = ({
|
||||
children,
|
||||
variant,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: ThemeButtonProps): ReactElement => {
|
||||
const buttonVariant = disabled ? 'disabled' : variant || 'default';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(themeButtonVariants({ variant: buttonVariant }), className)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
154
src/components/shared/ViewRoleModal.tsx
Normal file
154
src/components/shared/ViewRoleModal.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { SecondaryButton, StatusBadge, Modal } from '@/components/shared';
|
||||
import type { Role } from '@/types/role';
|
||||
|
||||
interface ViewRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
roleId: string | null;
|
||||
onLoadRole: (id: string) => Promise<Role>;
|
||||
}
|
||||
|
||||
// Helper function to get scope badge variant
|
||||
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
||||
switch (scope.toLowerCase()) {
|
||||
case 'platform':
|
||||
return 'success';
|
||||
case 'tenant':
|
||||
return 'process';
|
||||
case 'module':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const ViewRoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
roleId,
|
||||
onLoadRole,
|
||||
}: ViewRoleModalProps): ReactElement | null => {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load role data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && roleId) {
|
||||
const loadRole = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await onLoadRole(roleId);
|
||||
setRole(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load role details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadRole();
|
||||
} else {
|
||||
setRole(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, roleId, onLoadRole]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="View Role Details"
|
||||
description="View role information"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && role && (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Role Name</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{role.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Role Code</label>
|
||||
<p className="text-sm text-[#0e1b2a] font-mono">{role.code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Scope</label>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getScopeVariant(role.scope)}>
|
||||
{role.scope}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Role ID</label>
|
||||
<p className="text-sm text-[#0e1b2a] font-mono">{role.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{role.description && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Description</h3>
|
||||
<div>
|
||||
<p className="text-sm text-[#0e1b2a]">{role.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(role.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(role.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
169
src/components/shared/ViewTenantModal.tsx
Normal file
169
src/components/shared/ViewTenantModal.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, SecondaryButton, StatusBadge } from '@/components/shared';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
|
||||
interface ViewTenantModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tenantId: string | null;
|
||||
onLoadTenant: (id: string) => Promise<Tenant>;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'blocked':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const ViewTenantModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
tenantId,
|
||||
onLoadTenant,
|
||||
}: ViewTenantModalProps): ReactElement | null => {
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load tenant data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && tenantId) {
|
||||
const loadTenant = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await onLoadTenant(tenantId);
|
||||
setTenant(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load tenant details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTenant();
|
||||
} else {
|
||||
setTenant(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, tenantId, onLoadTenant]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="View Tenant Details"
|
||||
description="View tenant information"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className="p-5">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && tenant && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant Name</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{tenant.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Slug</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{tenant.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Status</label>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||
{tenant.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Settings</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Timezone</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{tenant.settings?.timezone || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Subscription Tier</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{tenant.subscription_tier ? tenant.subscription_tier.charAt(0).toUpperCase() + tenant.subscription_tier.slice(1) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Max Users</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{tenant.max_users ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Max Modules</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{tenant.max_modules ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(tenant.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(tenant.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
154
src/components/shared/ViewUserModal.tsx
Normal file
154
src/components/shared/ViewUserModal.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, SecondaryButton, StatusBadge } from '@/components/shared';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'blocked':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
interface ViewUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId: string | null;
|
||||
onLoadUser: (id: string) => Promise<User>;
|
||||
}
|
||||
|
||||
export const ViewUserModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userId,
|
||||
onLoadUser,
|
||||
}: ViewUserModalProps): ReactElement | null => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load user data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
const loadUser = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await onLoadUser(userId);
|
||||
setUser(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load user details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
} else {
|
||||
setUser(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, userId, onLoadUser]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="View User Details"
|
||||
description="View user information"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className="p-5">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && user && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Email</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Full Name</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Status</label>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
||||
{user.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Auth Provider</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{user.auth_provider}</p>
|
||||
</div>
|
||||
{user.tenant_id && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant ID</label>
|
||||
<p className="text-sm text-[#0e1b2a] font-mono">{user.tenant_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(user.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
25
src/components/shared/index.ts
Normal file
25
src/components/shared/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export { PrimaryButton } from './PrimaryButton';
|
||||
export { SecondaryButton } from './SecondaryButton';
|
||||
export { ThemeButton } from './ThemeButton';
|
||||
export { ActionDropdown } from './ActionDropdown';
|
||||
export { FormField } from './FormField';
|
||||
export { FormSelect } from './FormSelect';
|
||||
export { PaginatedSelect } from './PaginatedSelect';
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { Modal } from './Modal';
|
||||
export { DataTable } from './DataTable';
|
||||
export type { Column } from './DataTable';
|
||||
export { Pagination } from './Pagination';
|
||||
export { FilterDropdown } from './FilterDropdown';
|
||||
export { NewTenantModal } from './NewTenantModal';
|
||||
export { ViewTenantModal } from './ViewTenantModal';
|
||||
export { EditTenantModal } from './EditTenantModal';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { NewUserModal } from './NewUserModal';
|
||||
export { ViewUserModal } from './ViewUserModal';
|
||||
export { EditUserModal } from './EditUserModal';
|
||||
export { NewRoleModal } from './NewRoleModal';
|
||||
export { ViewRoleModal } from './ViewRoleModal';
|
||||
export { EditRoleModal } from './EditRoleModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
62
src/components/ui/button.tsx
Normal file
62
src/components/ui/button.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
29
src/features/dashboard/components/DashboardTabs.tsx
Normal file
29
src/features/dashboard/components/DashboardTabs.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const tabs = ['Overview', 'Tenants', 'Users', 'Roles', 'Modules', 'Audit Logs'];
|
||||
|
||||
export const DashboardTabs = ({ activeTab, onTabChange }: DashboardTabsProps) => {
|
||||
return (
|
||||
<div className="border border-[rgba(0,0,0,0.2)] rounded-[5px] p-1.5 flex gap-1.5 md:gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={cn(
|
||||
'flex-shrink-0 min-w-[60px] md:min-w-[76px] rounded px-2 md:px-3 py-1.5 text-xs font-medium text-center transition-all flex items-center justify-center min-h-[44px]',
|
||||
activeTab === tab
|
||||
? 'bg-[#084cc8] text-white'
|
||||
: 'bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] text-[#0e1b2a] hover:bg-[#e9ecef]'
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
src/features/dashboard/components/QuickActions.tsx
Normal file
42
src/features/dashboard/components/QuickActions.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, UserPlus, Shield, Settings } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import type { QuickAction } from '@/types/dashboard';
|
||||
|
||||
export const QuickActions = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ icon: Plus, label: 'New Tenant', onClick: () => navigate('/tenants') },
|
||||
{ icon: UserPlus, label: 'Invite User', onClick: () => navigate('/users') },
|
||||
{ icon: Shield, label: 'Add Role', onClick: () => navigate('/roles') },
|
||||
{ icon: Settings, label: 'Config', onClick: () => console.log('Config') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="w-[300px] shrink-0">
|
||||
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-4 pt-4 px-5 h-12">
|
||||
<h2 className="text-[15px] font-semibold text-[#0f1724] h-[19px]">Quick Actions</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{quickActions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
className="bg-white border border-dashed border-[rgba(0,0,0,0.08)] rounded-md p-[17px] flex flex-col gap-2 items-center justify-center hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
<span className="text-xs font-medium text-[#0f1724] text-center h-4 leading-4">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
140
src/features/dashboard/components/RecentActivity.tsx
Normal file
140
src/features/dashboard/components/RecentActivity.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { ChevronDown, Filter } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import type { ActivityLog } from '@/types/dashboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const activityData: ActivityLog[] = [
|
||||
{
|
||||
action: 'CREATE',
|
||||
resourceType: 'USER',
|
||||
resourceId: 'john@acme.com',
|
||||
ipAddress: '192.168.1.100',
|
||||
timestamp: '2 mins ago',
|
||||
},
|
||||
{
|
||||
action: 'UPDATE',
|
||||
resourceType: 'TENANT',
|
||||
resourceId: 'acme-medical',
|
||||
ipAddress: '10.0.0.21',
|
||||
timestamp: '5 mins ago',
|
||||
},
|
||||
{
|
||||
action: 'SUSPEND',
|
||||
resourceType: 'USER',
|
||||
resourceId: 'alice@globex.io',
|
||||
ipAddress: '172.16.3.8',
|
||||
timestamp: '12 mins ago',
|
||||
},
|
||||
{
|
||||
action: 'LOGIN',
|
||||
resourceType: 'SESSION',
|
||||
resourceId: 'session-98f2a',
|
||||
ipAddress: '192.168.10.4',
|
||||
timestamp: '20 mins ago',
|
||||
},
|
||||
{
|
||||
action: 'LOGOUT',
|
||||
resourceType: 'SESSION',
|
||||
resourceId: 'session-77bd1',
|
||||
ipAddress: '192.168.10.4',
|
||||
timestamp: '28 mins ago',
|
||||
},
|
||||
{
|
||||
action: 'CREATE',
|
||||
resourceType: 'MODULE',
|
||||
resourceId: 'hello-world-v1',
|
||||
ipAddress: '10.1.0.15',
|
||||
timestamp: '35 mins ago',
|
||||
},
|
||||
];
|
||||
|
||||
const getActionBadgeClass = (action: ActivityLog['action']): string => {
|
||||
const baseClass = 'px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase h-[14px]';
|
||||
switch (action) {
|
||||
case 'CREATE':
|
||||
return cn(baseClass, 'bg-[rgba(16,185,129,0.1)] text-[#059669]');
|
||||
case 'UPDATE':
|
||||
case 'LOGIN':
|
||||
return cn(baseClass, 'bg-[rgba(37,99,235,0.1)] text-[#2563eb]');
|
||||
case 'SUSPEND':
|
||||
return cn(baseClass, 'bg-[rgba(245,158,11,0.1)] text-[#d97706]');
|
||||
case 'LOGOUT':
|
||||
return cn(baseClass, 'bg-white text-[#6b7280]');
|
||||
default:
|
||||
return baseClass;
|
||||
}
|
||||
};
|
||||
|
||||
export const RecentActivity = () => {
|
||||
return (
|
||||
<Card className="flex-1 border-[rgba(0,0,0,0.2)] w-full">
|
||||
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-3 md:pb-4 pt-3 md:pt-4 px-4 md:px-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-2">
|
||||
<h2 className="text-sm md:text-[15px] font-semibold text-[#0f1724]">Recent Activity</h2>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded px-2.5 py-1.5 flex items-center gap-1.5 flex-1 sm:flex-initial">
|
||||
<span className="text-[11px] font-medium text-[#0f1724]">Action</span>
|
||||
<span className="text-[11px] font-normal text-[#6b7280]">All</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="w-px h-4 bg-[rgba(0,0,0,0.08)] hidden sm:block" />
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-1 min-h-[44px]">
|
||||
<Filter className="w-3 h-3" />
|
||||
<span className="text-[11px] font-normal text-[#6b7280] hidden md:inline">More filters</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="bg-white border-b border-[rgba(0,0,0,0.08)]">
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Resource Type
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Resource ID
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Timestamp
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activityData.map((activity, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="border-b border-[rgba(0,0,0,0.08)] last:border-b-0"
|
||||
>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5">
|
||||
<span className={getActionBadgeClass(activity.action)}>
|
||||
{activity.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-medium text-[#0f1724]">
|
||||
{activity.resourceType}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#0f1724] whitespace-nowrap">
|
||||
{activity.resourceId}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2 md:py-4 text-xs md:text-[13px] font-normal text-[#6b7280]">
|
||||
{activity.ipAddress}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#6b7280] whitespace-nowrap">
|
||||
{activity.timestamp}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
38
src/features/dashboard/components/StatCard.tsx
Normal file
38
src/features/dashboard/components/StatCard.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StatCardData } from '@/types/dashboard';
|
||||
|
||||
interface StatCardProps {
|
||||
data: StatCardData;
|
||||
}
|
||||
|
||||
export const StatCard = ({ data }: StatCardProps) => {
|
||||
const Icon = data.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-[17px] flex flex-col gap-3 h-[107px] overflow-hidden">
|
||||
<div className="flex items-start justify-between">
|
||||
<Icon className="w-[18px] h-[18px] shrink-0" />
|
||||
{data.badge && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[11px] font-semibold h-[14px] whitespace-nowrap',
|
||||
data.badge.variant === 'green'
|
||||
? 'bg-[rgba(16,185,129,0.1)] text-[#16a34a]'
|
||||
: 'bg-white text-[#6b7280]'
|
||||
)}
|
||||
>
|
||||
{data.badge.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.48px] whitespace-nowrap">
|
||||
{data.value}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-[#6b7280] whitespace-nowrap">
|
||||
{data.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
src/features/dashboard/components/StatsGrid.tsx
Normal file
52
src/features/dashboard/components/StatsGrid.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Building2, CheckCircle2, Users, TrendingUp, Package, Heart } from 'lucide-react';
|
||||
import { StatCard } from './StatCard';
|
||||
import type { StatCardData } from '@/types/dashboard';
|
||||
|
||||
const statsData: StatCardData[] = [
|
||||
{
|
||||
icon: Building2,
|
||||
value: '12',
|
||||
label: 'Total Tenants',
|
||||
badge: { text: '+3 this week', variant: 'green' },
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
value: '10',
|
||||
label: 'Active Tenants',
|
||||
badge: { text: '92% Rate', variant: 'green' },
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
value: '156',
|
||||
label: 'Total Users',
|
||||
badge: { text: '+24 new', variant: 'green' },
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
value: '23',
|
||||
label: 'Active Sessions',
|
||||
badge: { text: 'Peak 38', variant: 'gray' },
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
value: '5',
|
||||
label: 'Registered Modules',
|
||||
badge: { text: 'Last 24h', variant: 'gray' },
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
value: '4',
|
||||
label: 'Healthy Modules',
|
||||
badge: { text: '100% Uptime', variant: 'green' },
|
||||
},
|
||||
];
|
||||
|
||||
export const StatsGrid = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 mb-4 md:mb-6 auto-rows-fr">
|
||||
{statsData.map((stat, index) => (
|
||||
<StatCard key={index} data={stat} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/features/dashboard/components/SystemHealth.tsx
Normal file
60
src/features/dashboard/components/SystemHealth.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HealthMetric } from '@/types/dashboard';
|
||||
|
||||
const healthMetrics: HealthMetric[] = [
|
||||
{ label: 'API Latency', value: '45ms', percentage: 100, variant: 'success' },
|
||||
{ label: 'Database Load', value: '42%', percentage: 42, variant: 'info' },
|
||||
{ label: 'Storage Usage', value: '78%', percentage: 78, variant: 'warning' },
|
||||
];
|
||||
|
||||
const getVariantStyles = (variant: HealthMetric['variant']) => {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return { text: 'text-[#059669]', bg: 'bg-[#059669]' };
|
||||
case 'info':
|
||||
return { text: 'text-[#23dce1]', bg: 'bg-[#23dce1]' };
|
||||
case 'warning':
|
||||
return { text: 'text-[#f59e0b]', bg: 'bg-[#f59e0b]' };
|
||||
}
|
||||
};
|
||||
|
||||
export const SystemHealth = () => {
|
||||
return (
|
||||
<Card className="w-[300px] shrink-0">
|
||||
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-4 pt-4 px-5 h-12 flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0f1724] h-[19px]">System Health</h2>
|
||||
<Activity className="w-4 h-4" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{healthMetrics.map((metric, index) => {
|
||||
const styles = getVariantStyles(metric.variant);
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[#0f1724] h-4 leading-4">
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className={cn('text-xs font-medium h-4 leading-4', styles.text)}>
|
||||
{metric.value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.2)] rounded-full h-1.5 overflow-hidden relative">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[-1px] bottom-[-1px] left-[-1px] rounded-full',
|
||||
styles.bg
|
||||
)}
|
||||
style={{ width: `${metric.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
6
src/hooks/redux-hooks.ts
Normal file
6
src/hooks/redux-hooks.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '@/store/store';
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
123
src/index.css
Normal file
123
src/index.css
Normal file
@ -0,0 +1,123 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
17
src/main.tsx
Normal file
17
src/main.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { store, persistor } from './store/store';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<App />
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
18
src/pages/AuditLogs.tsx
Normal file
18
src/pages/AuditLogs.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Layout } from "@/components/layout/Layout"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
const AuditLogs = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Audit Logs"
|
||||
pageHeader={{
|
||||
title: 'Audit Logs',
|
||||
description: 'View and manage all audit logs in the QAssure platform.',
|
||||
}}
|
||||
>
|
||||
<div>Audit Logs</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuditLogs
|
||||
32
src/pages/Dashboard.tsx
Normal file
32
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { StatsGrid } from '@/features/dashboard/components/StatsGrid';
|
||||
import { RecentActivity } from '@/features/dashboard/components/RecentActivity';
|
||||
import { QuickActions } from '@/features/dashboard/components/QuickActions';
|
||||
import { SystemHealth } from '@/features/dashboard/components/SystemHealth';
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Dashboard Overview"
|
||||
pageHeader={{
|
||||
title: 'Platform Overview',
|
||||
description: 'Monitor system health, tenant activity, and user metrics in real-time.',
|
||||
}}
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid />
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 md:gap-6 items-start mt-4 md:mt-6">
|
||||
<RecentActivity />
|
||||
<div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0">
|
||||
<QuickActions />
|
||||
<SystemHealth />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
182
src/pages/Login.tsx
Normal file
182
src/pages/Login.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { loginAsync, clearError } from '@/store/authSlice';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import type { LoginError } from '@/services/auth-service';
|
||||
|
||||
// Zod validation schema
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
const Login = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading, error, isAuthenticated } = useAppSelector((state) => state.auth);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
clearErrors,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onBlur', // Validate on blur for better UX
|
||||
});
|
||||
|
||||
const [generalError, setGeneralError] = useState<string>('');
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Clear errors when component mounts
|
||||
useEffect(() => {
|
||||
dispatch(clearError());
|
||||
setGeneralError('');
|
||||
}, [dispatch]);
|
||||
|
||||
const onSubmit = async (data: LoginFormData, event?: React.BaseSyntheticEvent): Promise<void> => {
|
||||
// Explicitly prevent form default submission
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
setGeneralError('');
|
||||
clearErrors();
|
||||
|
||||
try {
|
||||
const result = await dispatch(loginAsync(data)).unwrap();
|
||||
if (result) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.payload) {
|
||||
const loginError = error.payload as LoginError;
|
||||
|
||||
if ('details' in loginError && Array.isArray(loginError.details)) {
|
||||
// Validation errors from server - set field-specific errors
|
||||
loginError.details.forEach((detail) => {
|
||||
if (detail.path === 'email' || detail.path === 'password') {
|
||||
setError(detail.path as keyof LoginFormData, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if ('error' in loginError && typeof loginError.error === 'object') {
|
||||
// General error from server
|
||||
setGeneralError(loginError.error.message || 'Login failed');
|
||||
} else {
|
||||
setGeneralError('An unexpected error occurred');
|
||||
}
|
||||
} else {
|
||||
setGeneralError(error?.message || 'Login failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Login Card */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-6 md:p-8">
|
||||
{/* Logo Section */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-[#112868] rounded-xl flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)]">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
|
||||
QAssure
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-[#6b7280]">
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* General Error Message */}
|
||||
{(generalError || error) && (
|
||||
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{generalError || error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-sm font-medium"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs md:text-sm text-[#6b7280]">
|
||||
© 2026 QAssure. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
18
src/pages/Modules.tsx
Normal file
18
src/pages/Modules.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Layout } from "@/components/layout/Layout"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
const Modules = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Modules"
|
||||
pageHeader={{
|
||||
title: 'module List',
|
||||
description: 'View and manage all system modules registered in the QAssure platform.',
|
||||
}}
|
||||
>
|
||||
<div>Modules</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modules
|
||||
64
src/pages/NotFound.tsx
Normal file
64
src/pages/NotFound.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import { Home, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const NotFound = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = (): void => {
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
const handleGoBack = (): void => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout currentPage="Page Not Found">
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
{/* 404 Number */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-8xl md:text-9xl font-bold text-[#112868] leading-none">
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<div className="mb-8 max-w-md">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-3">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-[#6b7280] leading-relaxed">
|
||||
The page you're looking for doesn't exist or has been moved. Please check the URL or
|
||||
navigate back to the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 md:gap-4 w-full sm:w-auto">
|
||||
<PrimaryButton
|
||||
onClick={handleGoHome}
|
||||
size="default"
|
||||
className="flex items-center justify-center gap-2 min-w-[160px]"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<span>Go to Dashboard</span>
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleGoBack}
|
||||
size="default"
|
||||
variant="default"
|
||||
className="flex items-center justify-center gap-2 min-w-[160px] bg-[#23dce1] text-[#112868] hover:bg-[#112868] hover:text-[#23dce1]"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Go Back</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
15
src/pages/ProtectedRoute.tsx
Normal file
15
src/pages/ProtectedRoute.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => {
|
||||
const { isAuthenticated } = useAppSelector((state) => state.auth);
|
||||
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
437
src/pages/Roles.tsx
Normal file
437
src/pages/Roles.tsx
Normal file
@ -0,0 +1,437 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewRoleModal,
|
||||
ViewRoleModal,
|
||||
EditRoleModal,
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { roleService } from '@/services/role-service';
|
||||
import type { Role } from '@/types/role';
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Helper function to get scope badge variant
|
||||
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
||||
switch (scope.toLowerCase()) {
|
||||
case 'platform':
|
||||
return 'success';
|
||||
case 'tenant':
|
||||
return 'process';
|
||||
case 'module':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const Roles = (): ReactElement => {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(20);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const fetchRoles = async (
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
scope: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
|
||||
if (response.success) {
|
||||
setRoles(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load roles');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
}, [currentPage, limit, scopeFilter, orderBy]);
|
||||
|
||||
const handleCreateRole = async (data: {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await roleService.create(data);
|
||||
setIsModalOpen(false);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// View role handler
|
||||
const handleViewRole = (roleId: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setViewModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit role handler
|
||||
const handleEditRole = (roleId: string, roleName: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setSelectedRoleName(roleName);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Update role handler
|
||||
const handleUpdateRole = async (
|
||||
id: string,
|
||||
data: {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await roleService.update(id, data);
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete role handler
|
||||
const handleDeleteRole = (roleId: string, roleName: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setSelectedRoleName(roleName);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete handler
|
||||
const handleConfirmDelete = async (): Promise<void> => {
|
||||
if (!selectedRoleId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await roleService.delete(selectedRoleId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load role for view/edit
|
||||
const loadRole = async (id: string): Promise<Role> => {
|
||||
const response = await roleService.getById(id);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns: Column<Role>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Role Name',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
label: 'Code',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
label: 'Scope',
|
||||
render: (role) => (
|
||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{role.description || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created Date',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (role) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card renderer
|
||||
const mobileCardRenderer = (role: Role) => (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Scope:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Created:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
||||
</div>
|
||||
{role.description && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-[#9aa6b2]">Description:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Roles"
|
||||
pageHeader={{
|
||||
title: 'Role List',
|
||||
description: 'Define and manage roles to control user access based on job responsibilities',
|
||||
}}
|
||||
>
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Scope Filter */}
|
||||
<FilterDropdown
|
||||
label="Scope"
|
||||
options={[
|
||||
{ value: 'platform', label: 'Platform' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'module', label: 'Module' },
|
||||
]}
|
||||
value={scopeFilter}
|
||||
onChange={(value) => {
|
||||
setScopeFilter(value as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
||||
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
|
||||
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
setOrderBy(value as string[] | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Default"
|
||||
showIcon
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New Role Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New Role</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
keyExtractor={(role) => role.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No roles found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Table Footer with Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Role Modal */}
|
||||
<NewRoleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateRole}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View Role Modal */}
|
||||
<ViewRoleModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
/>
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
<EditRoleModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
onSubmit={handleUpdateRole}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Role"
|
||||
message={`Are you sure you want to delete this role`}
|
||||
itemName={selectedRoleName}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Roles;
|
||||
553
src/pages/Tenants.tsx
Normal file
553
src/pages/Tenants.tsx
Normal file
@ -0,0 +1,553 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewTenantModal,
|
||||
ViewTenantModal,
|
||||
EditTenantModal,
|
||||
DeleteConfirmationModal,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
|
||||
// Helper function to get tenant initials
|
||||
const getTenantInitials = (name: string): string => {
|
||||
const words = name.trim().split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'blocked':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format subscription tier
|
||||
const formatSubscriptionTier = (tier: string | null): string => {
|
||||
if (!tier) return 'N/A';
|
||||
return tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||
};
|
||||
|
||||
const Tenants = (): ReactElement => {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
||||
const [selectedTenantName, setSelectedTenantName] = useState<string>('');
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const fetchTenants = async (
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
status: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await tenantService.getAll(page, itemsPerPage, status, sortBy);
|
||||
if (response.success) {
|
||||
setTenants(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load tenants');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load tenants');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
}, [currentPage, limit, statusFilter, orderBy]);
|
||||
|
||||
const handleCreateTenant = async (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
timezone: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await tenantService.create({
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
status: data.status,
|
||||
settings: {
|
||||
timezone: data.timezone,
|
||||
},
|
||||
});
|
||||
// Close modal and refresh tenant list
|
||||
setIsModalOpen(false);
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err; // Let the modal handle the error display
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// View tenant handler
|
||||
const handleViewTenant = (tenantId: string): void => {
|
||||
setSelectedTenantId(tenantId);
|
||||
setViewModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit tenant handler
|
||||
const handleEditTenant = (tenantId: string, tenantName: string): void => {
|
||||
setSelectedTenantId(tenantId);
|
||||
setSelectedTenantName(tenantName);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Update tenant handler
|
||||
const handleUpdateTenant = async (
|
||||
id: string,
|
||||
data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
timezone: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await tenantService.update(id, {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
status: data.status,
|
||||
settings: {
|
||||
timezone: data.timezone,
|
||||
},
|
||||
});
|
||||
setEditModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err; // Let the modal handle the error display
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete tenant handler
|
||||
const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
|
||||
setSelectedTenantId(tenantId);
|
||||
setSelectedTenantName(tenantName);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete handler
|
||||
const handleConfirmDelete = async (): Promise<void> => {
|
||||
if (!selectedTenantId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await tenantService.delete(selectedTenantId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
setSelectedTenantName('');
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err; // Let the modal handle the error display
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load tenant for view/edit
|
||||
const loadTenant = async (id: string): Promise<Tenant> => {
|
||||
const response = await tenantService.getById(id);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenants"
|
||||
pageHeader={{
|
||||
title: 'Tenant List',
|
||||
description: 'View and manage all tenants in your QAssure platform from a single place.',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Status Filter */}
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value as string | null);
|
||||
setCurrentPage(1); // Reset to first page when filter changes
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
setOrderBy(value as string[] | null);
|
||||
setCurrentPage(1); // Reset to first page when sort changes
|
||||
}}
|
||||
placeholder="Default"
|
||||
showIcon
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New Tenant Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New Tenant</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#6b7280]">Loading tenants...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Tenant Name
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Users
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Plan
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Modules
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
|
||||
Joined Date
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-[#9aa6b2]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-8 text-center text-sm text-[#6b7280]">
|
||||
No tenants found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<tr
|
||||
key={tenant.id}
|
||||
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* Tenant Name */}
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getTenantInitials(tenant.name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||
{tenant.status}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
|
||||
{/* Users */}
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.max_users ?? 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Plan */}
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{formatSubscriptionTier(tenant.subscription_tier)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Modules */}
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.max_modules ?? 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Joined Date */}
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{formatDate(tenant.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewTenant(tenant.id)}
|
||||
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
|
||||
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View */}
|
||||
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
|
||||
{tenants.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#6b7280]">No tenants found</p>
|
||||
</div>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<div key={tenant.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getTenantInitials(tenant.name)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{tenant.name}
|
||||
</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5">
|
||||
{formatDate(tenant.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewTenant(tenant.id)}
|
||||
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
|
||||
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||
{tenant.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Plan:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{formatSubscriptionTier(tenant.subscription_tier)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Users:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{tenant.max_users ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Modules:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{tenant.max_modules ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table Footer with Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1); // Reset to first page when limit changes
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Tenant Modal */}
|
||||
<NewTenantModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateTenant}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View Tenant Modal */}
|
||||
<ViewTenantModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
}}
|
||||
tenantId={selectedTenantId}
|
||||
onLoadTenant={loadTenant}
|
||||
/>
|
||||
|
||||
{/* Edit Tenant Modal */}
|
||||
<EditTenantModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
setSelectedTenantName('');
|
||||
}}
|
||||
tenantId={selectedTenantId}
|
||||
onLoadTenant={loadTenant}
|
||||
onSubmit={handleUpdateTenant}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
setSelectedTenantName('');
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Tenant"
|
||||
message="Are you sure you want to delete this tenant"
|
||||
itemName={selectedTenantName}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tenants;
|
||||
470
src/pages/Users.tsx
Normal file
470
src/pages/Users.tsx
Normal file
@ -0,0 +1,470 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewUserModal,
|
||||
ViewUserModal,
|
||||
EditUserModal,
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { userService } from '@/services/user-service';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'pending_verification':
|
||||
return 'process';
|
||||
case 'inactive':
|
||||
return 'failure';
|
||||
case 'blocked':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const Users = (): ReactElement => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedUserName, setSelectedUserName] = useState<string>('');
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const fetchUsers = async (
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
status: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await userService.getAll(page, itemsPerPage, status, sortBy);
|
||||
if (response.success) {
|
||||
setUsers(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load users');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load users');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
}, [currentPage, limit, statusFilter, orderBy]);
|
||||
|
||||
const handleCreateUser = async (data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
auth_provider: 'local';
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await userService.create(data);
|
||||
setIsModalOpen(false);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// View user handler
|
||||
const handleViewUser = (userId: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setViewModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit user handler
|
||||
const handleEditUser = (userId: string, userName: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedUserName(userName);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Update user handler
|
||||
const handleUpdateUser = async (
|
||||
id: string,
|
||||
data: {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await userService.update(id, data);
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete user handler
|
||||
const handleDeleteUser = (userId: string, userName: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedUserName(userName);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete handler
|
||||
const handleConfirmDelete = async (): Promise<void> => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await userService.delete(selectedUserId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load user for view/edit
|
||||
const loadUser = async (id: string): Promise<User> => {
|
||||
const response = await userService.getById(id);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'User Name',
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getUserInitials(user.first_name, user.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.first_name} {user.last_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
mobileLabel: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (user) => (
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'auth_provider',
|
||||
label: 'Auth Provider',
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Joined Date',
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
|
||||
),
|
||||
mobileLabel: 'Joined',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (user) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card renderer
|
||||
const mobileCardRenderer = (user: User) => (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getUserInitials(user.first_name, user.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Auth Provider:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Joined:</span>
|
||||
<p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Users"
|
||||
pageHeader={{
|
||||
title: 'User List',
|
||||
description: 'View and manage all users in your QAssure platform from a single place.',
|
||||
}}
|
||||
>
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Status Filter */}
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'pending_verification', label: 'Pending Verification' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value as string | null);
|
||||
setCurrentPage(1); // Reset to first page when filter changes
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
|
||||
{ value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
|
||||
{ value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
|
||||
{ value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
|
||||
{ value: ['email', 'asc'], label: 'Email (A-Z)' },
|
||||
{ value: ['email', 'desc'], label: 'Email (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
setOrderBy(value as string[] | null);
|
||||
setCurrentPage(1); // Reset to first page when sort changes
|
||||
}}
|
||||
placeholder="Default"
|
||||
showIcon
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New User Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New User</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
keyExtractor={(user) => user.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No users found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Table Footer with Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1); // Reset to first page when limit changes
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New User Modal */}
|
||||
<NewUserModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateUser}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View User Modal */}
|
||||
<ViewUserModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<EditUserModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
onSubmit={handleUpdateUser}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete User"
|
||||
message="Are you sure you want to delete this user"
|
||||
itemName={selectedUserName}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
62
src/services/api-client.ts
Normal file
62
src/services/api-client.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||
import type { RootState } from '@/store/store';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token to ALL requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Always try to get token from Redux store and add to Authorization header
|
||||
try {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
if (store) {
|
||||
const state = store.getState() as RootState;
|
||||
const token = state?.auth?.accessToken;
|
||||
|
||||
// Add Bearer token to all requests if token exists
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if store is not available
|
||||
console.warn('Redux store not available for token injection');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized - clear auth and redirect to login
|
||||
try {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
if (store) {
|
||||
store.dispatch({ type: 'auth/logout' });
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if store is not available
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
64
src/services/auth-service.ts
Normal file
64
src/services/auth-service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import apiClient from './api-client';
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user: User;
|
||||
tenant_id: string;
|
||||
roles: string[];
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
expires_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
success: false;
|
||||
error: 'Validation failed';
|
||||
details: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GeneralError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LoginError = ValidationError | GeneralError;
|
||||
|
||||
export interface LogoutResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
logout: async (): Promise<LogoutResponse> => {
|
||||
// Token will be automatically added by api-client interceptor
|
||||
const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
48
src/services/role-service.ts
Normal file
48
src/services/role-service.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import apiClient from './api-client';
|
||||
import type {
|
||||
RolesResponse,
|
||||
CreateRoleRequest,
|
||||
CreateRoleResponse,
|
||||
GetRoleResponse,
|
||||
UpdateRoleRequest,
|
||||
UpdateRoleResponse,
|
||||
DeleteRoleResponse,
|
||||
} from '@/types/role';
|
||||
|
||||
export const roleService = {
|
||||
getAll: async (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
scope?: string | null,
|
||||
orderBy?: string[] | null
|
||||
): Promise<RolesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (scope) {
|
||||
params.append('scope', scope);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
}
|
||||
const response = await apiClient.get<RolesResponse>(`/roles?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetRoleResponse> => {
|
||||
const response = await apiClient.get<GetRoleResponse>(`/roles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (data: CreateRoleRequest): Promise<CreateRoleResponse> => {
|
||||
const response = await apiClient.post<CreateRoleResponse>('/roles', data);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, data: UpdateRoleRequest): Promise<UpdateRoleResponse> => {
|
||||
const response = await apiClient.put<UpdateRoleResponse>(`/roles/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<DeleteRoleResponse> => {
|
||||
const response = await apiClient.delete<DeleteRoleResponse>(`/roles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
95
src/services/tenant-service.ts
Normal file
95
src/services/tenant-service.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import apiClient from './api-client';
|
||||
import type { Tenant, Pagination } from '@/types/tenant';
|
||||
|
||||
export interface TenantsResponse {
|
||||
success: boolean;
|
||||
data: Tenant[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
settings: {
|
||||
timezone: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTenantResponse {
|
||||
success: boolean;
|
||||
data: Tenant;
|
||||
}
|
||||
|
||||
export interface GeneralError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type TenantsError = GeneralError;
|
||||
|
||||
export interface GetTenantResponse {
|
||||
success: boolean;
|
||||
data: Tenant;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
settings: {
|
||||
timezone: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantResponse {
|
||||
success: boolean;
|
||||
data: Tenant;
|
||||
}
|
||||
|
||||
export interface DeleteTenantResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const tenantService = {
|
||||
getAll: async (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string | null,
|
||||
orderBy?: string[] | null
|
||||
): Promise<TenantsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (status) {
|
||||
params.append('status', status);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
// Send array as orderBy[]=field&orderBy[]=direction
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
}
|
||||
const response = await apiClient.get<TenantsResponse>(`/tenants?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (data: CreateTenantRequest): Promise<CreateTenantResponse> => {
|
||||
const response = await apiClient.post<CreateTenantResponse>('/tenants', data);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetTenantResponse> => {
|
||||
const response = await apiClient.get<GetTenantResponse>(`/tenants/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, data: UpdateTenantRequest): Promise<UpdateTenantResponse> => {
|
||||
const response = await apiClient.put<UpdateTenantResponse>(`/tenants/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<DeleteTenantResponse> => {
|
||||
const response = await apiClient.delete<DeleteTenantResponse>(`/tenants/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
49
src/services/user-service.ts
Normal file
49
src/services/user-service.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import apiClient from './api-client';
|
||||
import type {
|
||||
UsersResponse,
|
||||
CreateUserRequest,
|
||||
CreateUserResponse,
|
||||
GetUserResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserResponse,
|
||||
DeleteUserResponse,
|
||||
} from '@/types/user';
|
||||
|
||||
export const userService = {
|
||||
getAll: async (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string | null,
|
||||
orderBy?: string[] | null
|
||||
): Promise<UsersResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (status) {
|
||||
params.append('status', status);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
// Send array as orderBy[]=field&orderBy[]=direction
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
}
|
||||
const response = await apiClient.get<UsersResponse>(`/users?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (data: CreateUserRequest): Promise<CreateUserResponse> => {
|
||||
const response = await apiClient.post<CreateUserResponse>('/users', data);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetUserResponse> => {
|
||||
const response = await apiClient.get<GetUserResponse>(`/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, data: UpdateUserRequest): Promise<UpdateUserResponse> => {
|
||||
const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<DeleteUserResponse> => {
|
||||
const response = await apiClient.delete<DeleteUserResponse>(`/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
166
src/store/authSlice.ts
Normal file
166
src/store/authSlice.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError } from '@/services/auth-service';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
tenantId: string | null;
|
||||
roles: string[];
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
tokenType: string | null;
|
||||
expiresIn: number | null;
|
||||
expiresAt: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
tenantId: null,
|
||||
roles: [],
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenType: null,
|
||||
expiresIn: null,
|
||||
expiresAt: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async thunk for login
|
||||
export const loginAsync = createAsyncThunk<
|
||||
LoginResponse['data'],
|
||||
LoginRequest,
|
||||
{ rejectValue: LoginError }
|
||||
>('auth/login', async (credentials, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
}
|
||||
return rejectWithValue(response as unknown as LoginError);
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
return rejectWithValue(error.response.data as LoginError);
|
||||
}
|
||||
return rejectWithValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
},
|
||||
} as GeneralError);
|
||||
}
|
||||
});
|
||||
|
||||
// Async thunk for logout
|
||||
export const logoutAsync = createAsyncThunk('auth/logout', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
await authService.logout();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Even if API call fails, we should still logout locally
|
||||
return rejectWithValue(error?.response?.data || { message: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
state.tenantId = null;
|
||||
state.roles = [];
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.tokenType = null;
|
||||
state.expiresIn = null;
|
||||
state.expiresAt = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(loginAsync.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<LoginResponse['data']>) => {
|
||||
state.isLoading = false;
|
||||
state.user = action.payload.user;
|
||||
state.tenantId = action.payload.tenant_id;
|
||||
state.roles = action.payload.roles;
|
||||
state.accessToken = action.payload.access_token;
|
||||
state.refreshToken = action.payload.refresh_token;
|
||||
state.tokenType = action.payload.token_type;
|
||||
state.expiresIn = action.payload.expires_in;
|
||||
state.expiresAt = action.payload.expires_at;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginAsync.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.isAuthenticated = false;
|
||||
if (action.payload) {
|
||||
const error = action.payload;
|
||||
if ('error' in error && typeof error.error === 'object' && 'message' in error.error) {
|
||||
// General error
|
||||
state.error = error.error.message;
|
||||
} else {
|
||||
// Validation error or other
|
||||
state.error = 'Validation failed';
|
||||
}
|
||||
} else {
|
||||
state.error = action.error.message || 'Login failed';
|
||||
}
|
||||
})
|
||||
.addCase(logoutAsync.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
})
|
||||
.addCase(logoutAsync.fulfilled, (state) => {
|
||||
// Reset to initial state
|
||||
state.user = null;
|
||||
state.tenantId = null;
|
||||
state.roles = [];
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.tokenType = null;
|
||||
state.expiresIn = null;
|
||||
state.expiresAt = null;
|
||||
state.isAuthenticated = false;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(logoutAsync.rejected, (state) => {
|
||||
// Even if API call fails, clear local state
|
||||
state.user = null;
|
||||
state.tenantId = null;
|
||||
state.roles = [];
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.tokenType = null;
|
||||
state.expiresIn = null;
|
||||
state.expiresAt = null;
|
||||
state.isAuthenticated = false;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { logout, clearError } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
35
src/store/store.ts
Normal file
35
src/store/store.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { persistStore, persistReducer } from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import authReducer from './authSlice';
|
||||
|
||||
// Persist config for auth slice only
|
||||
const authPersistConfig = {
|
||||
key: 'auth',
|
||||
storage,
|
||||
whitelist: ['user', 'tenantId', 'roles', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'],
|
||||
};
|
||||
|
||||
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: persistedAuthReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE', 'persist/PAUSE', 'persist/PURGE', 'persist/REGISTER', 'persist/FLUSH'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
// Make store available globally for axios interceptor
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__REDUX_STORE__ = store;
|
||||
}
|
||||
30
src/types/dashboard.ts
Normal file
30
src/types/dashboard.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export interface StatCardData {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: string | number;
|
||||
label: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant: 'green' | 'gray';
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActivityLog {
|
||||
action: 'CREATE' | 'UPDATE' | 'SUSPEND' | 'LOGIN' | 'LOGOUT';
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
ipAddress: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface HealthMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
percentage: number;
|
||||
variant: 'success' | 'info' | 'warning';
|
||||
}
|
||||
57
src/types/role.ts
Normal file
57
src/types/role.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface RolesResponse {
|
||||
success: boolean;
|
||||
data: Role[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
}
|
||||
|
||||
export interface CreateRoleResponse {
|
||||
success: boolean;
|
||||
data: Role;
|
||||
}
|
||||
|
||||
export interface GetRoleResponse {
|
||||
success: boolean;
|
||||
data: Role;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
}
|
||||
|
||||
export interface UpdateRoleResponse {
|
||||
success: boolean;
|
||||
data: Role;
|
||||
}
|
||||
|
||||
export interface DeleteRoleResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
24
src/types/tenant.ts
Normal file
24
src/types/tenant.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export interface TenantSettings {
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
settings: TenantSettings | null;
|
||||
subscription_tier: string | null;
|
||||
max_users: number | null;
|
||||
max_modules: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
67
src/types/user.ts
Normal file
67
src/types/user.ts
Normal file
@ -0,0 +1,67 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
auth_provider: string;
|
||||
tenant_id?: string;
|
||||
role_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
success: boolean;
|
||||
data: User[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
auth_provider: 'local';
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
success: boolean;
|
||||
data: User;
|
||||
}
|
||||
|
||||
export interface GetUserResponse {
|
||||
success: boolean;
|
||||
data: User;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'blocked';
|
||||
auth_provider?: string;
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserResponse {
|
||||
success: boolean;
|
||||
data: User;
|
||||
}
|
||||
|
||||
export interface DeleteUserResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
21
src/utils/auth.ts
Normal file
21
src/utils/auth.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { RootState } from '@/store/store';
|
||||
|
||||
// Get store instance for checking auth state
|
||||
const getStore = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (window as any).__REDUX_STORE__;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
const store = getStore();
|
||||
if (store) {
|
||||
const state = store.getState() as RootState;
|
||||
return state.auth.isAuthenticated && Boolean(state.auth.accessToken);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Note: logout is now handled by Redux action
|
||||
// Use: dispatch(logout()) from components
|
||||
34
tsconfig.app.json
Normal file
34
tsconfig.app.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from "path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(),react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user