Initial commit - Frontend de template-saas migrado desde monorepo
Migración desde workspace-v2/projects/template-saas/apps/frontend Este repositorio es parte del estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cb173017b3
commit
eb95d0e276
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# Development files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Template SaaS Frontend Configuration
|
||||
# Copy this file to .env and adjust values
|
||||
|
||||
# API URL (proxied in development)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
# Tenant ID (for development - in production, extracted from subdomain)
|
||||
VITE_TENANT_ID=default-tenant
|
||||
|
||||
# Stripe publishable key (for client-side Stripe.js)
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage - nginx
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nginx-user && \
|
||||
adduser -S nginx-user -u 1001 -G nginx-user && \
|
||||
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
|
||||
chown -R nginx-user:nginx-user /var/cache/nginx && \
|
||||
chown -R nginx-user:nginx-user /var/log/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nginx-user:nginx-user /var/run/nginx.pid
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
44
eslint.config.js
Normal file
44
eslint.config.js
Normal file
@ -0,0 +1,44 @@
|
||||
import globals from 'globals';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'warn',
|
||||
'no-console': 'warn',
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'tests/**', '*.config.js', '*.config.ts'],
|
||||
},
|
||||
];
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<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>Template SaaS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
nginx.conf
Normal file
42
nginx.conf
Normal file
@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
6342
package-lock.json
generated
Normal file
6342
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@template-saas/frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --report-unused-disable-directives",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
64
playwright.config.ts
Normal file
64
playwright.config.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E Test Configuration
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3150',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Setup project for authentication
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
// Mobile viewport tests
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
// Web server configuration for local development
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3150',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
156
public/sw.js
Normal file
156
public/sw.js
Normal file
@ -0,0 +1,156 @@
|
||||
// Service Worker for Push Notifications
|
||||
// Template SaaS v2.0
|
||||
|
||||
const CACHE_NAME = 'template-saas-v1';
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Service worker activated');
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Push notification received
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push notification received');
|
||||
|
||||
let data = {
|
||||
title: 'Nueva notificacion',
|
||||
body: 'Tienes una nueva notificacion',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
url: '/',
|
||||
data: {},
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
data = { ...data, ...event.data.json() };
|
||||
} catch (e) {
|
||||
console.error('[SW] Error parsing push data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon || '/icon-192.png',
|
||||
badge: data.badge || '/badge-72.png',
|
||||
vibrate: [100, 50, 100],
|
||||
tag: data.notificationId || 'default',
|
||||
renotify: true,
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
notificationId: data.notificationId,
|
||||
...data.data,
|
||||
},
|
||||
actions: data.actions || [
|
||||
{ action: 'view', title: 'Ver' },
|
||||
{ action: 'dismiss', title: 'Descartar' },
|
||||
],
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[SW] Notification clicked:', event.action);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Try to find an existing window and navigate
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
return client.focus().then((focusedClient) => {
|
||||
if (focusedClient && 'navigate' in focusedClient) {
|
||||
return focusedClient.navigate(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no window found, open a new one
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Notification close handler
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[SW] Notification closed');
|
||||
|
||||
// Optionally track notification dismissals
|
||||
const notificationId = event.notification.data?.notificationId;
|
||||
if (notificationId) {
|
||||
// Could send analytics here
|
||||
}
|
||||
});
|
||||
|
||||
// Push subscription change handler
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
console.log('[SW] Push subscription changed');
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.pushManager
|
||||
.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: event.oldSubscription?.options?.applicationServerKey,
|
||||
})
|
||||
.then((subscription) => {
|
||||
// Notify the app about the new subscription
|
||||
return self.clients.matchAll().then((clients) => {
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({
|
||||
type: 'PUSH_SUBSCRIPTION_CHANGED',
|
||||
subscription: JSON.stringify(subscription),
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Message handler (for app communication)
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('[SW] Message received:', event.data);
|
||||
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// Background sync (for offline notifications)
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[SW] Background sync:', event.tag);
|
||||
|
||||
if (event.tag === 'sync-notifications') {
|
||||
event.waitUntil(syncNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
async function syncNotifications() {
|
||||
// This would sync any pending notification actions
|
||||
console.log('[SW] Syncing notifications...');
|
||||
}
|
||||
|
||||
console.log('[SW] Service worker loaded');
|
||||
3
public/vite.svg
Normal file
3
public/vite.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
12
src/App.tsx
Normal file
12
src/App.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AppRouter } from './router';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
219
src/__tests__/components/ExportButton.test.tsx
Normal file
219
src/__tests__/components/ExportButton.test.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
// Mock the hook
|
||||
vi.mock('@/hooks/useExport', () => ({
|
||||
useExportReport: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { ExportButton } from '@/components/common/ExportButton';
|
||||
import { useExportReport } from '@/hooks/useExport';
|
||||
|
||||
const mockUseExportReport = vi.mocked(useExportReport);
|
||||
const mockExportReport = vi.fn();
|
||||
|
||||
describe('ExportButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExportReport.mockResolvedValue(undefined);
|
||||
mockUseExportReport.mockReturnValue({
|
||||
exportReport: mockExportReport,
|
||||
isExporting: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render export button with text', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be enabled by default', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
render(<ExportButton reportType="users" disabled />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /export/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<ExportButton reportType="users" className="custom-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropdown behavior', () => {
|
||||
it('should not show dropdown initially', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Excel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('CSV')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show dropdown when clicked', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /export/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
expect(screen.getByText('Excel')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSV')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close dropdown when clicked again', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /export/i });
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close dropdown when clicking outside', () => {
|
||||
render(
|
||||
<div>
|
||||
<ExportButton reportType="users" />
|
||||
<button>Outside</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
fireEvent.click(exportButton);
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
|
||||
// Click outside
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: /outside/i }));
|
||||
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('export functionality', () => {
|
||||
it('should call exportReport with PDF format', async () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('PDF'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportReport).toHaveBeenCalledWith({
|
||||
reportType: 'users',
|
||||
format: 'pdf',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call exportReport with Excel format', async () => {
|
||||
render(<ExportButton reportType="billing" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('Excel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportReport).toHaveBeenCalledWith({
|
||||
reportType: 'billing',
|
||||
format: 'excel',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call exportReport with CSV format', async () => {
|
||||
render(<ExportButton reportType="audit" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportReport).toHaveBeenCalledWith({
|
||||
reportType: 'audit',
|
||||
format: 'csv',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should close dropdown after selecting format', async () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('PDF'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should disable button when exporting', () => {
|
||||
mockUseExportReport.mockReturnValue({
|
||||
exportReport: mockExportReport,
|
||||
isExporting: true,
|
||||
} as any);
|
||||
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /export/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not open dropdown when exporting', () => {
|
||||
mockUseExportReport.mockReturnValue({
|
||||
exportReport: mockExportReport,
|
||||
isExporting: true,
|
||||
} as any);
|
||||
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
|
||||
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('report types', () => {
|
||||
it('should support users report type', () => {
|
||||
render(<ExportButton reportType="users" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('PDF'));
|
||||
|
||||
expect(mockExportReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reportType: 'users' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should support billing report type', () => {
|
||||
render(<ExportButton reportType="billing" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('PDF'));
|
||||
|
||||
expect(mockExportReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reportType: 'billing' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should support audit report type', () => {
|
||||
render(<ExportButton reportType="audit" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /export/i }));
|
||||
fireEvent.click(screen.getByText('PDF'));
|
||||
|
||||
expect(mockExportReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reportType: 'audit' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/__tests__/components/NotificationBell.test.tsx
Normal file
168
src/__tests__/components/NotificationBell.test.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('@/hooks/useData', () => ({
|
||||
useUnreadNotificationsCount: vi.fn(),
|
||||
useNotifications: vi.fn(() => ({ data: { data: [] }, isLoading: false })),
|
||||
useMarkNotificationAsRead: vi.fn(() => ({ mutate: vi.fn() })),
|
||||
useMarkAllNotificationsAsRead: vi.fn(() => ({ mutate: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { NotificationBell } from '@/components/notifications/NotificationBell';
|
||||
import { useUnreadNotificationsCount } from '@/hooks/useData';
|
||||
|
||||
const mockUseUnreadNotificationsCount = vi.mocked(useUnreadNotificationsCount);
|
||||
|
||||
// Test wrapper with providers
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the bell button with notifications label', () => {
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
// Use aria-label to find the specific bell button
|
||||
const button = screen.getByRole('button', { name: /notifications/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible label when no unread notifications', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 0 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Notifications' });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show unread count in aria-label when there are unread notifications', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 5 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Notifications (5 unread)' });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unread count badge', () => {
|
||||
it('should not show badge when no unread notifications', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 0 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
// Badge should not exist for 0 count
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i });
|
||||
expect(bellButton.querySelector('span')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show badge with count when there are unread notifications', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 5 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show 99+ when count exceeds 99', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 150 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show exact count when count is 99', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 99 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show 99+ when count is 100', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: 100 },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawer interaction', () => {
|
||||
it('should open drawer when bell is clicked', () => {
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i });
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
// Look for the drawer header text
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined data gracefully', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: undefined,
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
// Should still render without crashing
|
||||
const button = screen.getByRole('button', { name: 'Notifications' });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle null count gracefully', () => {
|
||||
mockUseUnreadNotificationsCount.mockReturnValue({
|
||||
data: { count: null },
|
||||
} as any);
|
||||
|
||||
render(<NotificationBell />, { wrapper: createWrapper() });
|
||||
|
||||
// Should still render without crashing
|
||||
const button = screen.getByRole('button', { name: 'Notifications' });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/__tests__/setup.ts
Normal file
62
src/__tests__/setup.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Suppress console errors in tests (optional)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
236
src/__tests__/stores/auth.store.test.ts
Normal file
236
src/__tests__/stores/auth.store.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { useAuthStore, User } from '@/stores/auth.store';
|
||||
|
||||
// Reset store before each test
|
||||
beforeEach(() => {
|
||||
const { logout } = useAuthStore.getState();
|
||||
act(() => {
|
||||
logout();
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
const mockUser: User = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
role: 'admin',
|
||||
tenant_id: 'tenant-456',
|
||||
};
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have null user by default', () => {
|
||||
const { user } = useAuthStore.getState();
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('should have null tokens by default', () => {
|
||||
const { accessToken, refreshToken } = useAuthStore.getState();
|
||||
expect(accessToken).toBeNull();
|
||||
expect(refreshToken).toBeNull();
|
||||
});
|
||||
|
||||
it('should not be authenticated by default', () => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
expect(isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be loading by default', () => {
|
||||
const { isLoading } = useAuthStore.getState();
|
||||
expect(isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUser', () => {
|
||||
it('should set user', () => {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setUser(mockUser);
|
||||
});
|
||||
|
||||
const { user } = useAuthStore.getState();
|
||||
expect(user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('should update existing user', () => {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setUser(mockUser);
|
||||
});
|
||||
|
||||
const updatedUser = { ...mockUser, first_name: 'Jane' };
|
||||
|
||||
act(() => {
|
||||
setUser(updatedUser);
|
||||
});
|
||||
|
||||
const { user } = useAuthStore.getState();
|
||||
expect(user?.first_name).toBe('Jane');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTokens', () => {
|
||||
it('should set access and refresh tokens', () => {
|
||||
const { setTokens } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTokens('access-token-123', 'refresh-token-456');
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken } = useAuthStore.getState();
|
||||
expect(accessToken).toBe('access-token-123');
|
||||
expect(refreshToken).toBe('refresh-token-456');
|
||||
});
|
||||
|
||||
it('should update existing tokens', () => {
|
||||
const { setTokens } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTokens('old-access', 'old-refresh');
|
||||
setTokens('new-access', 'new-refresh');
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken } = useAuthStore.getState();
|
||||
expect(accessToken).toBe('new-access');
|
||||
expect(refreshToken).toBe('new-refresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should set user, tokens, and authentication status', () => {
|
||||
const { login } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
login(mockUser, 'access-token', 'refresh-token');
|
||||
});
|
||||
|
||||
const { user, accessToken, refreshToken, isAuthenticated, isLoading } = useAuthStore.getState();
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(accessToken).toBe('access-token');
|
||||
expect(refreshToken).toBe('refresh-token');
|
||||
expect(isAuthenticated).toBe(true);
|
||||
expect(isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop loading after login', () => {
|
||||
const { setLoading, login } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setLoading(true);
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(true);
|
||||
|
||||
act(() => {
|
||||
login(mockUser, 'access', 'refresh');
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear all authentication state', () => {
|
||||
const { login, logout } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
login(mockUser, 'access-token', 'refresh-token');
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
|
||||
act(() => {
|
||||
logout();
|
||||
});
|
||||
|
||||
const { user, accessToken, refreshToken, isAuthenticated } = useAuthStore.getState();
|
||||
expect(user).toBeNull();
|
||||
expect(accessToken).toBeNull();
|
||||
expect(refreshToken).toBeNull();
|
||||
expect(isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should work even if not logged in', () => {
|
||||
const { logout } = useAuthStore.getState();
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
logout();
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLoading', () => {
|
||||
it('should set loading to true', () => {
|
||||
const { setLoading } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setLoading(true);
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should set loading to false', () => {
|
||||
const { setLoading } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
setLoading(true);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should have persist configuration for auth-storage', () => {
|
||||
// The store is configured with persist middleware
|
||||
// We can verify the store name is correct by checking localStorage calls
|
||||
expect(useAuthStore.persist).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state isolation', () => {
|
||||
it('should not affect other state when setting user', () => {
|
||||
const { login, setUser } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
login(mockUser, 'access', 'refresh');
|
||||
});
|
||||
|
||||
const updatedUser = { ...mockUser, email: 'new@example.com' };
|
||||
|
||||
act(() => {
|
||||
setUser(updatedUser);
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, isAuthenticated } = useAuthStore.getState();
|
||||
expect(accessToken).toBe('access');
|
||||
expect(refreshToken).toBe('refresh');
|
||||
expect(isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should not affect other state when setting tokens', () => {
|
||||
const { login, setTokens } = useAuthStore.getState();
|
||||
|
||||
act(() => {
|
||||
login(mockUser, 'access', 'refresh');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setTokens('new-access', 'new-refresh');
|
||||
});
|
||||
|
||||
const { user, isAuthenticated } = useAuthStore.getState();
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
204
src/__tests__/stores/ui.store.test.ts
Normal file
204
src/__tests__/stores/ui.store.test.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
// Reset store before each test
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useUIStore.setState({
|
||||
sidebarOpen: true,
|
||||
theme: 'system',
|
||||
});
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useUIStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('should have sidebar open by default', () => {
|
||||
const { sidebarOpen } = useUIStore.getState();
|
||||
expect(sidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should have system theme by default', () => {
|
||||
const { theme } = useUIStore.getState();
|
||||
expect(theme).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSidebar', () => {
|
||||
it('should toggle sidebar from open to closed', () => {
|
||||
const { toggleSidebar } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
toggleSidebar();
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle sidebar from closed to open', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ sidebarOpen: false });
|
||||
});
|
||||
|
||||
const { toggleSidebar } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
toggleSidebar();
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle multiple times correctly', () => {
|
||||
const { toggleSidebar } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
toggleSidebar(); // false
|
||||
toggleSidebar(); // true
|
||||
toggleSidebar(); // false
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSidebarOpen', () => {
|
||||
it('should set sidebar to open', () => {
|
||||
act(() => {
|
||||
useUIStore.setState({ sidebarOpen: false });
|
||||
});
|
||||
|
||||
const { setSidebarOpen } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setSidebarOpen(true);
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should set sidebar to closed', () => {
|
||||
const { setSidebarOpen } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should not change state when setting same value', () => {
|
||||
const { setSidebarOpen } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setSidebarOpen(true);
|
||||
setSidebarOpen(true);
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTheme', () => {
|
||||
beforeEach(() => {
|
||||
// Mock document.documentElement
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
});
|
||||
|
||||
it('should set theme to light', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('light');
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().theme).toBe('light');
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
|
||||
it('should set theme to dark', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('dark');
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().theme).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('should set theme to system', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('system');
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().theme).toBe('system');
|
||||
});
|
||||
|
||||
it('should remove previous theme class when changing', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('dark');
|
||||
});
|
||||
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setTheme('light');
|
||||
});
|
||||
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply system preference when theme is system', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
// matchMedia is mocked to return matches: false (light mode)
|
||||
act(() => {
|
||||
setTheme('system');
|
||||
});
|
||||
|
||||
// Should apply light since matchMedia mock returns matches: false
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should have persist configuration for ui-storage', () => {
|
||||
expect(useUIStore.persist).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state isolation', () => {
|
||||
it('should not affect sidebar when changing theme', () => {
|
||||
const { setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('dark');
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should not affect theme when changing sidebar', () => {
|
||||
const { toggleSidebar, setTheme } = useUIStore.getState();
|
||||
|
||||
act(() => {
|
||||
setTheme('dark');
|
||||
toggleSidebar();
|
||||
});
|
||||
|
||||
expect(useUIStore.getState().theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
309
src/__tests__/utils/helpers.test.ts
Normal file
309
src/__tests__/utils/helpers.test.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Test utility functions commonly used in the frontend
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
describe('formatNumber', () => {
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
it('should format numbers less than 1000 as is', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(100)).toBe('100');
|
||||
expect(formatNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands with K suffix', () => {
|
||||
expect(formatNumber(1000)).toBe('1K');
|
||||
expect(formatNumber(1500)).toBe('1.5K');
|
||||
expect(formatNumber(10000)).toBe('10K');
|
||||
expect(formatNumber(999999)).toBe('1000K');
|
||||
});
|
||||
|
||||
it('should format millions with M suffix', () => {
|
||||
expect(formatNumber(1000000)).toBe('1M');
|
||||
expect(formatNumber(1500000)).toBe('1.5M');
|
||||
expect(formatNumber(10000000)).toBe('10M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
function formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount / 100); // Convert cents to dollars
|
||||
}
|
||||
|
||||
it('should format USD amounts', () => {
|
||||
expect(formatCurrency(0)).toBe('$0.00');
|
||||
expect(formatCurrency(100)).toBe('$1.00');
|
||||
expect(formatCurrency(1999)).toBe('$19.99');
|
||||
expect(formatCurrency(99900)).toBe('$999.00');
|
||||
});
|
||||
|
||||
it('should format different currencies', () => {
|
||||
expect(formatCurrency(1000, 'EUR')).toBe('€10.00');
|
||||
expect(formatCurrency(1000, 'GBP')).toBe('£10.00');
|
||||
});
|
||||
|
||||
it('should handle large amounts', () => {
|
||||
expect(formatCurrency(10000000)).toBe('$100,000.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText', () => {
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
it('should return text as is if shorter than maxLength', () => {
|
||||
expect(truncateText('Hello', 10)).toBe('Hello');
|
||||
expect(truncateText('Hello', 5)).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should truncate text and add ellipsis if longer', () => {
|
||||
expect(truncateText('Hello World', 8)).toBe('Hello...');
|
||||
expect(truncateText('This is a long text', 10)).toBe('This is...');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(truncateText('', 10)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(truncateText('abc', 3)).toBe('abc');
|
||||
expect(truncateText('abcd', 3)).toBe('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
it('should convert text to lowercase', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should replace spaces with hyphens', () => {
|
||||
expect(slugify('hello world')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should remove special characters', () => {
|
||||
expect(slugify('hello@world!')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('should handle multiple spaces', () => {
|
||||
expect(slugify('hello world')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should trim leading/trailing whitespace', () => {
|
||||
expect(slugify(' hello world ')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should handle underscores', () => {
|
||||
expect(slugify('hello_world')).toBe('hello-world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classNames (clsx alternative)', () => {
|
||||
function classNames(...classes: (string | undefined | null | boolean)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
it('should join class names', () => {
|
||||
expect(classNames('a', 'b', 'c')).toBe('a b c');
|
||||
});
|
||||
|
||||
it('should filter out falsy values', () => {
|
||||
expect(classNames('a', undefined, 'b', null, 'c')).toBe('a b c');
|
||||
expect(classNames('a', false, 'b', '', 'c')).toBe('a b c');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(classNames()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle all falsy values', () => {
|
||||
expect(classNames(undefined, null, false)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
it('should delay function execution', async () => {
|
||||
let callCount = 0;
|
||||
const fn = debounce(() => callCount++, 50);
|
||||
|
||||
fn();
|
||||
expect(callCount).toBe(0);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should only execute once for rapid calls', async () => {
|
||||
let callCount = 0;
|
||||
const fn = debounce(() => callCount++, 50);
|
||||
|
||||
fn();
|
||||
fn();
|
||||
fn();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQueryParams', () => {
|
||||
function parseQueryParams(url: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const queryString = url.split('?')[1];
|
||||
if (!queryString) return params;
|
||||
|
||||
queryString.split('&').forEach((param) => {
|
||||
const [key, value] = param.split('=');
|
||||
if (key) {
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value || '');
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
it('should parse query parameters', () => {
|
||||
const result = parseQueryParams('https://example.com?foo=bar&baz=qux');
|
||||
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
|
||||
});
|
||||
|
||||
it('should handle URL without query params', () => {
|
||||
const result = parseQueryParams('https://example.com');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle encoded parameters', () => {
|
||||
const result = parseQueryParams('https://example.com?name=John%20Doe');
|
||||
expect(result).toEqual({ name: 'John Doe' });
|
||||
});
|
||||
|
||||
it('should handle empty values', () => {
|
||||
const result = parseQueryParams('https://example.com?empty=');
|
||||
expect(result).toEqual({ empty: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
function formatDate(date: Date | string, format: 'short' | 'long' = 'short'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (format === 'long') {
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
it('should format date in short format', () => {
|
||||
// Use explicit UTC date to avoid timezone issues
|
||||
const result = formatDate(new Date(2024, 0, 15)); // Month is 0-indexed
|
||||
expect(result).toContain('Jan');
|
||||
expect(result).toContain('2024');
|
||||
});
|
||||
|
||||
it('should format date string', () => {
|
||||
const result = formatDate(new Date(2024, 0, 15));
|
||||
expect(result).toContain('Jan');
|
||||
});
|
||||
|
||||
it('should format date in long format', () => {
|
||||
const result = formatDate(new Date(2024, 0, 15, 10, 30, 0), 'long');
|
||||
expect(result).toContain('January');
|
||||
expect(result).toContain('2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
it('should validate correct emails', () => {
|
||||
expect(isValidEmail('test@example.com')).toBe(true);
|
||||
expect(isValidEmail('user.name@domain.co.uk')).toBe(true);
|
||||
expect(isValidEmail('user+tag@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid emails', () => {
|
||||
expect(isValidEmail('invalid')).toBe(false);
|
||||
expect(isValidEmail('@example.com')).toBe(false);
|
||||
expect(isValidEmail('test@')).toBe(false);
|
||||
expect(isValidEmail('test @example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitials', () => {
|
||||
function getInitials(firstName?: string, lastName?: string): string {
|
||||
const first = firstName?.charAt(0)?.toUpperCase() || '';
|
||||
const last = lastName?.charAt(0)?.toUpperCase() || '';
|
||||
return first + last || '?';
|
||||
}
|
||||
|
||||
it('should return initials from first and last name', () => {
|
||||
expect(getInitials('John', 'Doe')).toBe('JD');
|
||||
});
|
||||
|
||||
it('should handle missing last name', () => {
|
||||
expect(getInitials('John')).toBe('J');
|
||||
});
|
||||
|
||||
it('should handle missing first name', () => {
|
||||
expect(getInitials(undefined, 'Doe')).toBe('D');
|
||||
});
|
||||
|
||||
it('should return ? for no names', () => {
|
||||
expect(getInitials()).toBe('?');
|
||||
expect(getInitials('', '')).toBe('?');
|
||||
});
|
||||
|
||||
it('should handle lowercase names', () => {
|
||||
expect(getInitials('john', 'doe')).toBe('JD');
|
||||
});
|
||||
});
|
||||
});
|
||||
232
src/components/ai/AIChat.tsx
Normal file
232
src/components/ai/AIChat.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||
import { Send, Trash2, Settings2, Loader2 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { ChatMessage, ChatMessageProps } from './ChatMessage';
|
||||
import { useAIChat, useAIConfig, useAIModels } from '@/hooks/useAI';
|
||||
import { ChatMessage as ChatMessageType } from '@/services/api';
|
||||
|
||||
interface Message extends ChatMessageProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface AIChatProps {
|
||||
systemPrompt?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showModelSelector?: boolean;
|
||||
onUsageUpdate?: () => void;
|
||||
}
|
||||
|
||||
export function AIChat({
|
||||
systemPrompt,
|
||||
placeholder = 'Type your message...',
|
||||
className,
|
||||
showModelSelector = false,
|
||||
onUsageUpdate,
|
||||
}: AIChatProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data: config } = useAIConfig();
|
||||
const { data: models } = useAIModels();
|
||||
const chatMutation = useAIChat();
|
||||
|
||||
// Set default model from config
|
||||
useEffect(() => {
|
||||
if (config?.default_model && !selectedModel) {
|
||||
setSelectedModel(config.default_model);
|
||||
}
|
||||
}, [config, selectedModel]);
|
||||
|
||||
// Auto scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto resize textarea
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedInput = input.trim();
|
||||
if (!trimmedInput || chatMutation.isPending) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: trimmedInput,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput('');
|
||||
|
||||
// Reset textarea height
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
// Build message history for API
|
||||
const apiMessages: ChatMessageType[] = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (systemPrompt) {
|
||||
apiMessages.push({ role: 'system', content: systemPrompt });
|
||||
} else if (config?.system_prompt) {
|
||||
apiMessages.push({ role: 'system', content: config.system_prompt });
|
||||
}
|
||||
|
||||
// Add conversation history
|
||||
messages.forEach((msg) => {
|
||||
if (msg.role !== 'system') {
|
||||
apiMessages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
});
|
||||
|
||||
// Add current user message
|
||||
apiMessages.push({ role: 'user', content: trimmedInput });
|
||||
|
||||
// Add loading message
|
||||
const loadingId = `assistant-${Date.now()}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: loadingId, role: 'assistant', content: '', isLoading: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await chatMutation.mutateAsync({
|
||||
messages: apiMessages,
|
||||
model: selectedModel,
|
||||
temperature: config?.temperature,
|
||||
max_tokens: config?.max_tokens,
|
||||
});
|
||||
|
||||
// Replace loading message with actual response
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingId
|
||||
? {
|
||||
...msg,
|
||||
content: response.choices[0]?.message?.content || 'No response',
|
||||
isLoading: false,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
|
||||
onUsageUpdate?.();
|
||||
} catch {
|
||||
// Remove loading message on error
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== loadingId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('flex flex-col bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="w-4 h-4 text-secondary-500" />
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
AI Assistant
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showModelSelector && models && models.length > 0 && (
|
||||
<select
|
||||
value={selectedModel || ''}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="text-xs bg-secondary-100 dark:bg-secondary-700 border-0 rounded px-2 py-1 text-secondary-700 dark:text-secondary-300"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={clearChat}
|
||||
disabled={messages.length === 0}
|
||||
className="p-1.5 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto min-h-[300px] max-h-[500px]">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-secondary-400 py-12">
|
||||
<Settings2 className="w-12 h-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">Start a conversation</p>
|
||||
<p className="text-xs mt-1">Type a message below to begin</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} {...message} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<form onSubmit={handleSubmit} className="border-t border-secondary-200 dark:border-secondary-700 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={chatMutation.isPending || !config?.is_enabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-secondary-50 dark:bg-secondary-700 border-0 rounded-lg px-4 py-2.5 text-sm text-secondary-900 dark:text-white placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || chatMutation.isPending || !config?.is_enabled}
|
||||
className="flex-shrink-0 p-2.5 bg-primary-500 hover:bg-primary-600 disabled:bg-secondary-300 dark:disabled:bg-secondary-600 text-white rounded-lg transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
{chatMutation.isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!config?.is_enabled && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
AI is currently disabled. Enable it in settings to start chatting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
src/components/ai/AISettings.tsx
Normal file
300
src/components/ai/AISettings.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Bot, Zap, AlertCircle, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useAIConfig, useUpdateAIConfig, useAIModels, useCurrentAIUsage, useAIHealth } from '@/hooks/useAI';
|
||||
|
||||
interface AIConfigForm {
|
||||
is_enabled: boolean;
|
||||
default_model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
system_prompt: string;
|
||||
allow_custom_prompts: boolean;
|
||||
log_conversations: boolean;
|
||||
}
|
||||
|
||||
export function AISettings() {
|
||||
const { data: config, isLoading: configLoading } = useAIConfig();
|
||||
const { data: models } = useAIModels();
|
||||
const { data: usage } = useCurrentAIUsage();
|
||||
const { data: health } = useAIHealth();
|
||||
const updateConfig = useUpdateAIConfig();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isDirty },
|
||||
} = useForm<AIConfigForm>({
|
||||
values: config ? {
|
||||
is_enabled: config.is_enabled,
|
||||
default_model: config.default_model,
|
||||
temperature: config.temperature,
|
||||
max_tokens: config.max_tokens,
|
||||
system_prompt: config.system_prompt || '',
|
||||
allow_custom_prompts: config.allow_custom_prompts,
|
||||
log_conversations: config.log_conversations,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
const isEnabled = watch('is_enabled');
|
||||
const temperature = watch('temperature');
|
||||
|
||||
const onSubmit = async (data: AIConfigForm) => {
|
||||
await updateConfig.mutateAsync(data);
|
||||
};
|
||||
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 4,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center gap-3">
|
||||
<Bot className="w-5 h-5 text-secondary-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
AI Integration
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Configure AI assistant settings for your organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* Health Status */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
health?.status === 'healthy' ? 'bg-green-100 dark:bg-green-900/30' :
|
||||
health?.status === 'degraded' ? 'bg-yellow-100 dark:bg-yellow-900/30' :
|
||||
'bg-red-100 dark:bg-red-900/30'
|
||||
)}>
|
||||
{health?.status === 'healthy' ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
) : health?.status === 'degraded' ? (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{health?.status === 'healthy' ? 'AI Service Operational' :
|
||||
health?.status === 'degraded' ? 'AI Service Degraded' :
|
||||
'AI Service Unavailable'}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{health?.provider || 'OpenRouter'} - {health?.models_available || 0} models available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{health?.latency_ms && (
|
||||
<span className="text-sm text-secondary-500">
|
||||
{health.latency_ms}ms latency
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Stats */}
|
||||
{usage && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide">Requests</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{usage.request_count.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide">Tokens Used</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{usage.total_tokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide">Cost (Month)</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{formatCurrency(usage.total_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<p className="text-xs text-secondary-500 uppercase tracking-wide">Avg Latency</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{Math.round(usage.avg_latency_ms)}ms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card">
|
||||
<div className="card-header flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-secondary-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Configuration
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Customize AI behavior for your organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body space-y-6">
|
||||
{/* Enable AI */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">Enable AI</p>
|
||||
<p className="text-sm text-secondary-500">Allow AI features in your organization</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
{...register('is_enabled')}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div>
|
||||
<label className="label">Default Model</label>
|
||||
<select
|
||||
className="input"
|
||||
disabled={!isEnabled}
|
||||
{...register('default_model')}
|
||||
>
|
||||
{models?.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
This model will be used by default for all AI requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="label">Temperature: {temperature}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
disabled={!isEnabled}
|
||||
className="w-full h-2 bg-secondary-200 rounded-lg appearance-none cursor-pointer dark:bg-secondary-700 disabled:opacity-50"
|
||||
{...register('temperature', { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-secondary-500 mt-1">
|
||||
<span>Precise (0)</span>
|
||||
<span>Balanced (1)</span>
|
||||
<span>Creative (2)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
<div>
|
||||
<label className="label">Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min="100"
|
||||
max="128000"
|
||||
disabled={!isEnabled}
|
||||
{...register('max_tokens', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
Maximum number of tokens per response (100-128,000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<label className="label">System Prompt</label>
|
||||
<textarea
|
||||
className="input min-h-[100px]"
|
||||
placeholder="You are a helpful assistant..."
|
||||
disabled={!isEnabled}
|
||||
{...register('system_prompt')}
|
||||
/>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
Custom instructions for the AI assistant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Additional Settings */}
|
||||
<div className="space-y-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">Allow Custom Prompts</p>
|
||||
<p className="text-sm text-secondary-500">Let users override the system prompt</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
disabled={!isEnabled}
|
||||
{...register('allow_custom_prompts')}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">Log Conversations</p>
|
||||
<p className="text-sm text-secondary-500">Store conversation history for analytics</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
disabled={!isEnabled}
|
||||
{...register('log_conversations')}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{updateConfig.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/ai/ChatMessage.tsx
Normal file
83
src/components/ai/ChatMessage.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Bot, User } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ChatMessageProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, timestamp, isLoading }: ChatMessageProps) {
|
||||
const isUser = role === 'user';
|
||||
const isSystem = role === 'system';
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center my-2">
|
||||
<span className="text-xs text-secondary-500 bg-secondary-100 dark:bg-secondary-800 px-3 py-1 rounded-full">
|
||||
{content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-3 p-4',
|
||||
isUser ? 'flex-row-reverse' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUser
|
||||
? 'bg-primary-100 dark:bg-primary-900/30'
|
||||
: 'bg-secondary-100 dark:bg-secondary-800'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col max-w-[80%]',
|
||||
isUser ? 'items-end' : 'items-start'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-2xl px-4 py-2',
|
||||
isUser
|
||||
? 'bg-primary-500 text-white rounded-tr-sm'
|
||||
: 'bg-secondary-100 dark:bg-secondary-800 text-secondary-900 dark:text-white rounded-tl-sm'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
{timestamp && (
|
||||
<span className="text-xs text-secondary-400 mt-1">
|
||||
{timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/ai/index.ts
Normal file
4
src/components/ai/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { AIChat } from './AIChat';
|
||||
export { AISettings } from './AISettings';
|
||||
export { ChatMessage } from './ChatMessage';
|
||||
export type { ChatMessageProps } from './ChatMessage';
|
||||
104
src/components/analytics/MetricCard.tsx
Normal file
104
src/components/analytics/MetricCard.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { LucideIcon, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: number;
|
||||
trend?: 'up' | 'down';
|
||||
icon?: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
format?: 'number' | 'currency' | 'percentage';
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
trend,
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
format = 'number',
|
||||
subtitle,
|
||||
}: MetricCardProps) {
|
||||
// Format the value based on type
|
||||
const formatValue = (val: string | number): string => {
|
||||
if (typeof val === 'string') return val;
|
||||
|
||||
switch (format) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(val);
|
||||
case 'percentage':
|
||||
return `${val.toFixed(1)}%`;
|
||||
case 'number':
|
||||
default:
|
||||
return new Intl.NumberFormat('en-US').format(val);
|
||||
}
|
||||
};
|
||||
|
||||
// Format change percentage
|
||||
const formatChange = (val: number): string => {
|
||||
const sign = val >= 0 ? '+' : '';
|
||||
return `${sign}${val.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-10 w-10 bg-secondary-200 dark:bg-secondary-700 rounded-lg" />
|
||||
<div className="h-5 w-16 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-24 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
<div className="h-4 w-20 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trendColor =
|
||||
trend === 'up'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: trend === 'down'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-secondary-500 dark:text-secondary-400';
|
||||
|
||||
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && (
|
||||
<div className={clsx('flex items-center gap-1 text-sm font-medium', trendColor)}>
|
||||
{TrendIcon && <TrendIcon className="w-4 h-4" />}
|
||||
<span>{formatChange(change)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/analytics/TrendChart.tsx
Normal file
166
src/components/analytics/TrendChart.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
TooltipProps,
|
||||
} from 'recharts';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TrendChartProps {
|
||||
data: TrendDataPoint[];
|
||||
title: string;
|
||||
color?: string;
|
||||
isLoading?: boolean;
|
||||
height?: number;
|
||||
showGrid?: boolean;
|
||||
valueFormatter?: (value: number) => string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
// Custom tooltip component
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter,
|
||||
dateFormat,
|
||||
}: TooltipProps<number, string> & {
|
||||
valueFormatter?: (value: number) => string;
|
||||
dateFormat?: string;
|
||||
}) {
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = payload[0].value as number;
|
||||
const formattedDate = (() => {
|
||||
try {
|
||||
return format(parseISO(label), dateFormat || 'MMM d, yyyy');
|
||||
} catch {
|
||||
return label;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg shadow-lg p-3">
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-1">{formattedDate}</p>
|
||||
<p className="text-sm font-semibold text-secondary-900 dark:text-white">
|
||||
{valueFormatter ? valueFormatter(value) : value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendChart({
|
||||
data,
|
||||
title,
|
||||
color = '#3b82f6', // primary blue
|
||||
isLoading = false,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
valueFormatter,
|
||||
dateFormat = 'MMM d',
|
||||
}: TrendChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-5 w-32 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
<div
|
||||
className="bg-secondary-200 dark:bg-secondary-700 rounded"
|
||||
style={{ height: height - 40 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format X axis dates
|
||||
const formatXAxis = (dateStr: string): string => {
|
||||
try {
|
||||
return format(parseISO(dateStr), dateFormat);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate min and max for better Y axis
|
||||
const values = data.map((d) => d.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const padding = (maxValue - minValue) * 0.1 || 10;
|
||||
const yMin = Math.max(0, Math.floor(minValue - padding));
|
||||
const yMax = Math.ceil(maxValue + padding);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center text-secondary-500 dark:text-secondary-400"
|
||||
style={{ height: height - 40 }}
|
||||
>
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={height - 40}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
{showGrid && (
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="currentColor"
|
||||
className="text-secondary-200 dark:text-secondary-700"
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatXAxis}
|
||||
tick={{ fill: 'currentColor', fontSize: 12 }}
|
||||
className="text-secondary-500 dark:text-secondary-400"
|
||||
axisLine={{ stroke: 'currentColor' }}
|
||||
tickLine={{ stroke: 'currentColor' }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[yMin, yMax]}
|
||||
tick={{ fill: 'currentColor', fontSize: 12 }}
|
||||
className="text-secondary-500 dark:text-secondary-400"
|
||||
axisLine={{ stroke: 'currentColor' }}
|
||||
tickLine={{ stroke: 'currentColor' }}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip valueFormatter={valueFormatter} dateFormat="MMMM d, yyyy" />
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: color,
|
||||
stroke: 'white',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/analytics/index.ts
Normal file
2
src/components/analytics/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './MetricCard';
|
||||
export * from './TrendChart';
|
||||
168
src/components/audit/ActivityTimeline.tsx
Normal file
168
src/components/audit/ActivityTimeline.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { ActivityLog } from '@/services/api';
|
||||
import { getActivityTypeLabel } from '@/hooks/useAudit';
|
||||
import {
|
||||
Eye,
|
||||
Zap,
|
||||
Search,
|
||||
Download,
|
||||
Upload,
|
||||
Share2,
|
||||
UserPlus,
|
||||
Settings,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
activities: ActivityLog[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
page_view: Eye,
|
||||
feature_use: Zap,
|
||||
search: Search,
|
||||
download: Download,
|
||||
upload: Upload,
|
||||
share: Share2,
|
||||
invite: UserPlus,
|
||||
settings_change: Settings,
|
||||
subscription_change: CreditCard,
|
||||
payment: DollarSign,
|
||||
};
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
page_view: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
feature_use: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
search: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
download: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
|
||||
upload: 'bg-cyan-100 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400',
|
||||
share: 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
invite: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||
settings_change: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
subscription_change: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
payment: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
};
|
||||
|
||||
export function ActivityTimeline({ activities, isLoading }: ActivityTimelineProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3 animate-pulse">
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-200 dark:bg-secondary-700" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-secondary-200 dark:bg-secondary-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-secondary-200 dark:bg-secondary-700 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Activity className="w-12 h-12 mx-auto text-secondary-400 mb-3" />
|
||||
<p className="text-secondary-500">No recent activity</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-secondary-200 dark:bg-secondary-700" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => {
|
||||
const Icon = iconMap[activity.activity_type] || Activity;
|
||||
const colorClass =
|
||||
colorMap[activity.activity_type] ||
|
||||
'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400';
|
||||
|
||||
const userName = activity.user
|
||||
? `${activity.user.first_name || ''} ${activity.user.last_name || ''}`.trim() ||
|
||||
activity.user.email
|
||||
: 'Unknown';
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="relative flex gap-4 pl-0">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative z-10 flex items-center justify-center w-10 h-10 rounded-full',
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{getActivityTypeLabel(activity.activity_type)}
|
||||
</p>
|
||||
{activity.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-0.5">
|
||||
{activity.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
{userName}
|
||||
{activity.resource_type && (
|
||||
<span className="ml-1">
|
||||
- {activity.resource_type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-secondary-400 whitespace-nowrap">
|
||||
{formatTime(activity.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activity.metadata && Object.keys(activity.metadata).length > 0 && (
|
||||
<div className="mt-2 p-2 bg-secondary-50 dark:bg-secondary-800/50 rounded text-xs">
|
||||
{Object.entries(activity.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<span className="text-secondary-500">{key}:</span>
|
||||
<span className="text-secondary-700 dark:text-secondary-300">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/components/audit/AuditFilters.tsx
Normal file
193
src/components/audit/AuditFilters.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { AuditAction, QueryAuditLogsParams } from '@/services/api';
|
||||
import { getAuditActionLabel } from '@/hooks/useAudit';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditFiltersProps {
|
||||
filters: QueryAuditLogsParams;
|
||||
onFiltersChange: (filters: QueryAuditLogsParams) => void;
|
||||
entityTypes?: string[];
|
||||
}
|
||||
|
||||
const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'read',
|
||||
'login',
|
||||
'logout',
|
||||
'export',
|
||||
'import',
|
||||
];
|
||||
|
||||
export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: AuditFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const activeFiltersCount = [
|
||||
filters.action,
|
||||
filters.entity_type,
|
||||
filters.user_id,
|
||||
filters.from_date,
|
||||
filters.to_date,
|
||||
].filter(Boolean).length;
|
||||
|
||||
const handleChange = (key: keyof QueryAuditLogsParams, value: string | undefined) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[key]: value || undefined,
|
||||
page: 1, // Reset to first page on filter change
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
onFiltersChange({ page: 1, limit: filters.limit });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filter toggle button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isOpen || activeFiltersCount > 0
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{isOpen && (
|
||||
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Action filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={filters.action || ''}
|
||||
onChange={(e) => handleChange('action', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
{AUDIT_ACTIONS.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{getAuditActionLabel(action)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Entity type filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Entity Type
|
||||
</label>
|
||||
<select
|
||||
value={filters.entity_type || ''}
|
||||
onChange={(e) => handleChange('entity_type', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">All entities</option>
|
||||
{entityTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* From date filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
From Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from_date || ''}
|
||||
onChange={(e) => handleChange('from_date', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* To date filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
To Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to_date || ''}
|
||||
onChange={(e) => handleChange('to_date', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter badges */}
|
||||
{activeFiltersCount > 0 && !isOpen && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.action && (
|
||||
<FilterBadge
|
||||
label={`Action: ${getAuditActionLabel(filters.action)}`}
|
||||
onRemove={() => handleChange('action', undefined)}
|
||||
/>
|
||||
)}
|
||||
{filters.entity_type && (
|
||||
<FilterBadge
|
||||
label={`Entity: ${filters.entity_type}`}
|
||||
onRemove={() => handleChange('entity_type', undefined)}
|
||||
/>
|
||||
)}
|
||||
{filters.from_date && (
|
||||
<FilterBadge
|
||||
label={`From: ${filters.from_date}`}
|
||||
onRemove={() => handleChange('from_date', undefined)}
|
||||
/>
|
||||
)}
|
||||
{filters.to_date && (
|
||||
<FilterBadge
|
||||
label={`To: ${filters.to_date}`}
|
||||
onRemove={() => handleChange('to_date', undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterBadge({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 rounded-full text-sm">
|
||||
{label}
|
||||
<button onClick={onRemove} className="hover:text-primary-900 dark:hover:text-primary-200">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
203
src/components/audit/AuditLogRow.tsx
Normal file
203
src/components/audit/AuditLogRow.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { AuditLog } from '@/services/api';
|
||||
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
|
||||
import { ChevronDown, ChevronRight, User, Clock, Globe, Code } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditLogRowProps {
|
||||
log: AuditLog;
|
||||
onSelect?: (log: AuditLog) => void;
|
||||
}
|
||||
|
||||
export function AuditLogRow({ log, onSelect }: AuditLogRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasChanges = log.old_values || log.new_values;
|
||||
const userName = log.user
|
||||
? `${log.user.first_name || ''} ${log.user.last_name || ''}`.trim() || log.user.email
|
||||
: 'System';
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-700 last:border-b-0">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-4 px-4 py-3 hover:bg-secondary-50 dark:hover:bg-secondary-800/50 cursor-pointer',
|
||||
expanded && 'bg-secondary-50 dark:bg-secondary-800/50'
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<button className="p-1 text-secondary-400">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 rounded-full text-xs font-medium',
|
||||
getAuditActionColor(log.action)
|
||||
)}
|
||||
>
|
||||
{getAuditActionLabel(log.action)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{log.entity_type}
|
||||
</span>
|
||||
{log.entity_id && (
|
||||
<span className="text-xs text-secondary-500 font-mono truncate max-w-[120px]">
|
||||
{log.entity_id.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{log.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">{log.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
||||
<User className="w-4 h-4" />
|
||||
<span className="max-w-[100px] truncate">{userName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm text-secondary-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDate(log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 py-3 bg-secondary-50 dark:bg-secondary-800/30 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||||
<div>
|
||||
<span className="text-secondary-500">User:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">
|
||||
{log.user?.email || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-secondary-500">Entity ID:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100 font-mono text-xs">
|
||||
{log.entity_id || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
{log.ip_address && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="w-4 h-4 text-secondary-400" />
|
||||
<span className="text-secondary-500">IP:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">{log.ip_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.endpoint && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Code className="w-4 h-4 text-secondary-400" />
|
||||
<span className="text-secondary-500">Endpoint:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100 font-mono text-xs">
|
||||
{log.http_method} {log.endpoint}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{log.duration_ms && (
|
||||
<div>
|
||||
<span className="text-secondary-500">Duration:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">{log.duration_ms}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{log.response_status && (
|
||||
<div>
|
||||
<span className="text-secondary-500">Status:</span>{' '}
|
||||
<span
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
log.response_status < 400
|
||||
? 'text-green-600'
|
||||
: log.response_status < 500
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
)}
|
||||
>
|
||||
{log.response_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="space-y-2">
|
||||
{log.changed_fields && log.changed_fields.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary-500 uppercase">
|
||||
Changed Fields:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{log.changed_fields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs"
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{log.old_values && Object.keys(log.old_values).length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary-500 uppercase">
|
||||
Old Values:
|
||||
</span>
|
||||
<pre className="mt-1 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs overflow-auto max-h-32">
|
||||
{JSON.stringify(log.old_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{log.new_values && Object.keys(log.new_values).length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary-500 uppercase">
|
||||
New Values:
|
||||
</span>
|
||||
<pre className="mt-1 p-2 bg-green-50 dark:bg-green-900/20 rounded text-xs overflow-auto max-h-32">
|
||||
{JSON.stringify(log.new_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs font-medium text-secondary-500 uppercase">Metadata:</span>
|
||||
<pre className="mt-1 p-2 bg-secondary-100 dark:bg-secondary-700 rounded text-xs overflow-auto max-h-24">
|
||||
{JSON.stringify(log.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSelect && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(log);
|
||||
}}
|
||||
className="mt-3 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
View full details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/audit/AuditStatsCard.tsx
Normal file
165
src/components/audit/AuditStatsCard.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { AuditStats } from '@/services/api';
|
||||
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
|
||||
import { Activity, TrendingUp, Users, FileText } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditStatsCardProps {
|
||||
stats: AuditStats;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-secondary-200 dark:bg-secondary-700 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-20 bg-secondary-200 dark:bg-secondary-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topActions = Object.entries(stats.by_action)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4);
|
||||
|
||||
const topEntityTypes = Object.entries(stats.by_entity_type)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Audit Overview
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
||||
<Activity className="w-4 h-4" />
|
||||
Last 7 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-primary-600 dark:text-primary-400 mb-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Total Logs</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{stats.total_logs.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 mb-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Actions</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{Object.keys(stats.by_action).length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 mb-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Entities</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{Object.keys(stats.by_entity_type).length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-purple-600 dark:text-purple-400 mb-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Active Users</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{stats.top_users.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions breakdown */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Actions by Type
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{topActions.map(([action, count]) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 rounded-full text-xs font-medium',
|
||||
getAuditActionColor(action as any)
|
||||
)}
|
||||
>
|
||||
{getAuditActionLabel(action as any)}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Top Entity Types
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{topEntityTypes.map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-900 dark:text-secondary-100 capitalize">
|
||||
{type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily trend */}
|
||||
{stats.by_day && stats.by_day.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Daily Activity
|
||||
</h4>
|
||||
<div className="flex items-end justify-between h-16 gap-1">
|
||||
{stats.by_day.map((day) => {
|
||||
const maxCount = Math.max(...stats.by_day.map((d) => d.count));
|
||||
const height = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
title={`${day.date}: ${day.count} logs`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
/>
|
||||
<span className="text-xs text-secondary-400">
|
||||
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'narrow' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/audit/index.ts
Normal file
4
src/components/audit/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './AuditLogRow';
|
||||
export * from './AuditStatsCard';
|
||||
export * from './AuditFilters';
|
||||
export * from './ActivityTimeline';
|
||||
193
src/components/auth/OAuthButtons.tsx
Normal file
193
src/components/auth/OAuthButtons.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useOAuthUrl } from '@/hooks/useOAuth';
|
||||
|
||||
export type OAuthMode = 'login' | 'register' | 'link';
|
||||
export type OAuthProvider = 'google' | 'microsoft' | 'github' | 'apple';
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
mode: OAuthMode;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
id: OAuthProvider;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
bgColor: string;
|
||||
hoverBgColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
// Google Icon Component
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Microsoft Icon Component
|
||||
function MicrosoftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.4 11.4H0V0h11.4v11.4z" fill="#F25022"/>
|
||||
<path d="M24 11.4H12.6V0H24v11.4z" fill="#7FBA00"/>
|
||||
<path d="M11.4 24H0V12.6h11.4V24z" fill="#00A4EF"/>
|
||||
<path d="M24 24H12.6V12.6H24V24z" fill="#FFB900"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub Icon Component
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple Icon Component
|
||||
function AppleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.79 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.53 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.27 2.33-1.97 4.27-3.74 4.25z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const providers: ProviderConfig[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: <GoogleIcon className="w-5 h-5" />,
|
||||
bgColor: 'bg-white',
|
||||
hoverBgColor: 'hover:bg-gray-50',
|
||||
textColor: 'text-gray-700',
|
||||
},
|
||||
{
|
||||
id: 'microsoft',
|
||||
name: 'Microsoft',
|
||||
icon: <MicrosoftIcon className="w-5 h-5" />,
|
||||
bgColor: 'bg-white',
|
||||
hoverBgColor: 'hover:bg-gray-50',
|
||||
textColor: 'text-gray-700',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
icon: <GitHubIcon className="w-5 h-5" />,
|
||||
bgColor: 'bg-gray-900',
|
||||
hoverBgColor: 'hover:bg-gray-800',
|
||||
textColor: 'text-white',
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
name: 'Apple',
|
||||
icon: <AppleIcon className="w-5 h-5" />,
|
||||
bgColor: 'bg-black',
|
||||
hoverBgColor: 'hover:bg-gray-900',
|
||||
textColor: 'text-white',
|
||||
},
|
||||
];
|
||||
|
||||
export function OAuthButtons({ mode, onSuccess, onError, disabled }: OAuthButtonsProps) {
|
||||
const [loadingProvider, setLoadingProvider] = useState<OAuthProvider | null>(null);
|
||||
const getOAuthUrl = useOAuthUrl();
|
||||
|
||||
const handleOAuthClick = async (provider: OAuthProvider) => {
|
||||
if (loadingProvider || disabled) return;
|
||||
|
||||
setLoadingProvider(provider);
|
||||
|
||||
try {
|
||||
const response = await getOAuthUrl.mutateAsync({
|
||||
provider,
|
||||
mode,
|
||||
});
|
||||
|
||||
// Redirect to the OAuth provider
|
||||
if (response.url) {
|
||||
window.location.href = response.url;
|
||||
onSuccess?.();
|
||||
} else {
|
||||
throw new Error('No authorization URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to initiate OAuth');
|
||||
toast.error(err.message);
|
||||
onError?.(err);
|
||||
setLoadingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonLabel = (providerName: string) => {
|
||||
switch (mode) {
|
||||
case 'login':
|
||||
return `Continue with ${providerName}`;
|
||||
case 'register':
|
||||
return `Sign up with ${providerName}`;
|
||||
case 'link':
|
||||
return `Connect ${providerName}`;
|
||||
default:
|
||||
return `Continue with ${providerName}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => {
|
||||
const isLoading = loadingProvider === provider.id;
|
||||
const isDisabled = disabled || (loadingProvider !== null && loadingProvider !== provider.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthClick(provider.id)}
|
||||
disabled={isDisabled || isLoading}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-3 px-4 py-2.5
|
||||
border border-secondary-300 dark:border-secondary-600 rounded-lg
|
||||
font-medium transition-colors duration-200
|
||||
${provider.bgColor} ${provider.hoverBgColor} ${provider.textColor}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
dark:bg-secondary-800 dark:hover:bg-secondary-700 dark:text-white
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
provider.icon
|
||||
)}
|
||||
<span>{getButtonLabel(provider.name)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Separator component for use between form and OAuth buttons
|
||||
export function OAuthSeparator() {
|
||||
return (
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-secondary-300 dark:border-secondary-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white dark:bg-secondary-900 text-secondary-500">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/auth/index.ts
Normal file
1
src/components/auth/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './OAuthButtons';
|
||||
84
src/components/common/ExportButton.tsx
Normal file
84
src/components/common/ExportButton.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Download, FileSpreadsheet, FileText, File, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { useExportReport } from '@/hooks/useExport';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type ReportType = 'users' | 'billing' | 'audit';
|
||||
export type ExportFormat = 'pdf' | 'excel' | 'csv';
|
||||
|
||||
interface ExportButtonProps {
|
||||
reportType: ReportType;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatOptions: { value: ExportFormat; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'pdf', label: 'PDF', icon: <FileText className="w-4 h-4" /> },
|
||||
{ value: 'excel', label: 'Excel', icon: <FileSpreadsheet className="w-4 h-4" /> },
|
||||
{ value: 'csv', label: 'CSV', icon: <File className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
export function ExportButton({ reportType, disabled = false, className }: ExportButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { exportReport, isExporting } = useExportReport();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleExport = async (format: ExportFormat) => {
|
||||
setIsOpen(false);
|
||||
await exportReport({ reportType, format });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled || isExporting}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg',
|
||||
'bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600',
|
||||
'text-secondary-700 dark:text-secondary-300',
|
||||
'hover:bg-secondary-50 dark:hover:bg-secondary-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-secondary-900',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
<span>Export</span>
|
||||
<ChevronDown className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
|
||||
{formatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleExport(option.value)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/common/ExportModal.tsx
Normal file
231
src/components/common/ExportModal.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Download, FileSpreadsheet, FileText, File, Loader2, Calendar } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { ReportType, ExportFormat } from './ExportButton';
|
||||
|
||||
export interface ExportParams {
|
||||
format: ExportFormat;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
reportType: ReportType;
|
||||
onExport: (params: ExportParams) => Promise<void>;
|
||||
isExporting?: boolean;
|
||||
}
|
||||
|
||||
const formatOptions: { value: ExportFormat; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'pdf',
|
||||
label: 'PDF',
|
||||
description: 'Best for printing and sharing',
|
||||
icon: <FileText className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
value: 'excel',
|
||||
label: 'Excel',
|
||||
description: 'Best for data analysis in spreadsheets',
|
||||
icon: <FileSpreadsheet className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
value: 'csv',
|
||||
label: 'CSV',
|
||||
description: 'Universal format for data import',
|
||||
icon: <File className="w-5 h-5" />
|
||||
},
|
||||
];
|
||||
|
||||
const reportTypeLabels: Record<ReportType, string> = {
|
||||
users: 'Users Report',
|
||||
billing: 'Billing Report',
|
||||
audit: 'Audit Logs Report',
|
||||
};
|
||||
|
||||
export function ExportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
reportType,
|
||||
onExport,
|
||||
isExporting = false
|
||||
}: ExportModalProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('excel');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleExport = async () => {
|
||||
await onExport({
|
||||
format: selectedFormat,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-secondary-800 rounded-xl shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<Download className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Export {reportTypeLabels[reportType]}
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Choose format and date range
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isExporting}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 text-secondary-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Export Format
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{formatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setSelectedFormat(option.value)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-4 p-3 rounded-lg border-2 transition-all',
|
||||
selectedFormat === option.value
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-secondary-200 dark:border-secondary-700 hover:border-secondary-300 dark:hover:border-secondary-600'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'p-2 rounded-lg',
|
||||
selectedFormat === option.value
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400'
|
||||
)}>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className={clsx(
|
||||
'font-medium',
|
||||
selectedFormat === option.value
|
||||
? 'text-primary-700 dark:text-primary-300'
|
||||
: 'text-secondary-900 dark:text-secondary-100'
|
||||
)}>
|
||||
{option.label}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
|
||||
selectedFormat === option.value
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-secondary-300 dark:border-secondary-600'
|
||||
)}>
|
||||
{selectedFormat === option.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range (Optional) */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Calendar className="w-4 h-4 text-secondary-500" />
|
||||
<label className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Date Range
|
||||
</label>
|
||||
<span className="text-xs text-secondary-400">(Optional)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-xs text-secondary-500 mb-1">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dateTo" className="block text-xs text-secondary-500 mb-1">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isExporting}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/common/index.ts
Normal file
2
src/components/common/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ExportButton';
|
||||
export * from './ExportModal';
|
||||
172
src/components/feature-flags/FeatureFlagCard.tsx
Normal file
172
src/components/feature-flags/FeatureFlagCard.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { FeatureFlag } from '@/services/api';
|
||||
import {
|
||||
getFlagTypeLabel,
|
||||
getFlagTypeColor,
|
||||
getFlagScopeLabel,
|
||||
getFlagScopeColor,
|
||||
} from '@/hooks/useFeatureFlags';
|
||||
import { MoreVertical, Pencil, Trash2, ToggleLeft, ToggleRight, Copy } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface FeatureFlagCardProps {
|
||||
flag: FeatureFlag;
|
||||
onEdit: (flag: FeatureFlag) => void;
|
||||
onDelete: (flag: FeatureFlag) => void;
|
||||
onToggle: (flag: FeatureFlag) => void;
|
||||
isToggling?: boolean;
|
||||
}
|
||||
|
||||
export function FeatureFlagCard({
|
||||
flag,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
isToggling,
|
||||
}: FeatureFlagCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const copyKey = async () => {
|
||||
await navigator.clipboard.writeText(flag.key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-secondary-900 dark:text-secondary-100 truncate">
|
||||
{flag.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => onToggle(flag)}
|
||||
disabled={isToggling}
|
||||
className={clsx(
|
||||
'p-1 rounded transition-colors',
|
||||
flag.is_enabled
|
||||
? 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
: 'text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700'
|
||||
)}
|
||||
title={flag.is_enabled ? 'Disable flag' : 'Enable flag'}
|
||||
>
|
||||
{flag.is_enabled ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-0.5 rounded font-mono text-secondary-600 dark:text-secondary-400">
|
||||
{flag.key}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyKey}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
title="Copy key"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
{copied && (
|
||||
<span className="text-xs text-green-600">Copied!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-1.5 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-secondary-500" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-1 w-40 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit(flag);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onDelete(flag);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flag.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-3 line-clamp-2">
|
||||
{flag.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
getFlagTypeColor(flag.flag_type)
|
||||
)}
|
||||
>
|
||||
{getFlagTypeLabel(flag.flag_type)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
getFlagScopeColor(flag.scope)
|
||||
)}
|
||||
>
|
||||
{getFlagScopeLabel(flag.scope)}
|
||||
</span>
|
||||
{flag.category && (
|
||||
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 rounded-full text-xs">
|
||||
{flag.category}
|
||||
</span>
|
||||
)}
|
||||
{flag.rollout_percentage !== null && flag.rollout_percentage < 100 && (
|
||||
<span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 rounded-full text-xs">
|
||||
{flag.rollout_percentage}% rollout
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flag.default_value !== null && flag.default_value !== undefined && (
|
||||
<div className="mt-3 pt-3 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<span className="text-xs text-secondary-500">Default value:</span>
|
||||
<code className="ml-2 text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-0.5 rounded">
|
||||
{typeof flag.default_value === 'object'
|
||||
? JSON.stringify(flag.default_value)
|
||||
: String(flag.default_value)}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
src/components/feature-flags/FeatureFlagForm.tsx
Normal file
315
src/components/feature-flags/FeatureFlagForm.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { useState } from 'react';
|
||||
import { FeatureFlag, FlagType, FlagScope, CreateFlagRequest, UpdateFlagRequest } from '@/services/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface FeatureFlagFormProps {
|
||||
flag?: FeatureFlag;
|
||||
onSubmit: (data: CreateFlagRequest | UpdateFlagRequest) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const FLAG_TYPES: { value: FlagType; label: string }[] = [
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'string', label: 'String' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
];
|
||||
|
||||
const FLAG_SCOPES: { value: FlagScope; label: string; description: string }[] = [
|
||||
{ value: 'global', label: 'Global', description: 'Applies to all tenants and users' },
|
||||
{ value: 'tenant', label: 'Tenant', description: 'Can be overridden per tenant' },
|
||||
{ value: 'user', label: 'User', description: 'Can be overridden per user' },
|
||||
{ value: 'plan', label: 'Plan', description: 'Based on subscription plan' },
|
||||
];
|
||||
|
||||
export function FeatureFlagForm({ flag, onSubmit, onCancel, isLoading }: FeatureFlagFormProps) {
|
||||
const isEditing = !!flag;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
key: flag?.key || '',
|
||||
name: flag?.name || '',
|
||||
description: flag?.description || '',
|
||||
flag_type: flag?.flag_type || ('boolean' as FlagType),
|
||||
scope: flag?.scope || ('global' as FlagScope),
|
||||
default_value: flag?.default_value ?? '',
|
||||
is_enabled: flag?.is_enabled ?? false,
|
||||
rollout_percentage: flag?.rollout_percentage ?? 100,
|
||||
category: flag?.category || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateKey = (key: string): string | null => {
|
||||
if (!key) return 'Key is required';
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(key)) {
|
||||
return 'Key must start with lowercase letter and contain only lowercase letters, numbers, and underscores';
|
||||
}
|
||||
if (key.length > 100) return 'Key must be 100 characters or less';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!isEditing) {
|
||||
const keyError = validateKey(formData.key);
|
||||
if (keyError) newErrors.key = keyError;
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse default value based on type
|
||||
let defaultValue: any = formData.default_value;
|
||||
if (formData.flag_type === 'boolean') {
|
||||
defaultValue = formData.default_value === 'true' || formData.default_value === true;
|
||||
} else if (formData.flag_type === 'number') {
|
||||
defaultValue = parseFloat(formData.default_value as string) || 0;
|
||||
} else if (formData.flag_type === 'json') {
|
||||
try {
|
||||
defaultValue = formData.default_value ? JSON.parse(formData.default_value as string) : null;
|
||||
} catch {
|
||||
setErrors({ default_value: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const submitData: CreateFlagRequest | UpdateFlagRequest = {
|
||||
...(isEditing ? {} : { key: formData.key }),
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
flag_type: formData.flag_type,
|
||||
scope: formData.scope,
|
||||
default_value: defaultValue,
|
||||
is_enabled: formData.is_enabled,
|
||||
rollout_percentage: formData.rollout_percentage,
|
||||
category: formData.category.trim() || undefined,
|
||||
};
|
||||
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Key (only for create) */}
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Key *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, key: e.target.value.toLowerCase() });
|
||||
setErrors({ ...errors, key: '' });
|
||||
}}
|
||||
placeholder="e.g., enable_dark_mode"
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
{errors.key && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.key}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-secondary-500">
|
||||
Unique identifier. Lowercase letters, numbers, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
setErrors({ ...errors, name: '' });
|
||||
}}
|
||||
placeholder="e.g., Enable Dark Mode"
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Describe what this flag controls..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type and Scope */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.flag_type}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, flag_type: e.target.value as FlagType })
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{FLAG_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Scope
|
||||
</label>
|
||||
<select
|
||||
value={formData.scope}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, scope: e.target.value as FlagScope })
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{FLAG_SCOPES.map((scope) => (
|
||||
<option key={scope.value} value={scope.value}>
|
||||
{scope.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Default Value
|
||||
</label>
|
||||
{formData.flag_type === 'boolean' ? (
|
||||
<select
|
||||
value={String(formData.default_value)}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, default_value: e.target.value === 'true' })
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
</select>
|
||||
) : formData.flag_type === 'json' ? (
|
||||
<textarea
|
||||
value={
|
||||
typeof formData.default_value === 'object'
|
||||
? JSON.stringify(formData.default_value, null, 2)
|
||||
: formData.default_value
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, default_value: e.target.value })}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={formData.flag_type === 'number' ? 'number' : 'text'}
|
||||
value={formData.default_value}
|
||||
onChange={(e) => setFormData({ ...formData, default_value: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
)}
|
||||
{errors.default_value && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.default_value}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
placeholder="e.g., UI, Beta, Experimental"
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rollout Percentage */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Rollout Percentage: {formData.rollout_percentage}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.rollout_percentage}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, rollout_percentage: parseInt(e.target.value) })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500">
|
||||
Percentage of users who will see this flag enabled (for gradual rollouts)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_enabled"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="is_enabled"
|
||||
className="text-sm font-medium text-secondary-700 dark:text-secondary-300"
|
||||
>
|
||||
Enable flag globally
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEditing ? 'Update Flag' : 'Create Flag'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
187
src/components/feature-flags/TenantOverridesPanel.tsx
Normal file
187
src/components/feature-flags/TenantOverridesPanel.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { FeatureFlag, TenantFlag } from '@/services/api';
|
||||
import { Plus, Trash2, ToggleLeft, ToggleRight, AlertCircle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TenantOverridesPanelProps {
|
||||
flags: FeatureFlag[];
|
||||
overrides: TenantFlag[];
|
||||
onAdd: (flagId: string, isEnabled: boolean, value?: any) => void;
|
||||
onRemove: (flagId: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function TenantOverridesPanel({
|
||||
flags,
|
||||
overrides,
|
||||
onAdd,
|
||||
onRemove,
|
||||
isLoading,
|
||||
}: TenantOverridesPanelProps) {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [selectedFlagId, setSelectedFlagId] = useState('');
|
||||
const [overrideEnabled, setOverrideEnabled] = useState(true);
|
||||
|
||||
// Get flags that don't have overrides yet
|
||||
const overriddenFlagIds = new Set(overrides.map((o) => o.flag_id));
|
||||
const availableFlags = flags.filter((f) => !overriddenFlagIds.has(f.id));
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedFlagId) return;
|
||||
onAdd(selectedFlagId, overrideEnabled);
|
||||
setSelectedFlagId('');
|
||||
setOverrideEnabled(true);
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const getFlag = (flagId: string) => flags.find((f) => f.id === flagId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Tenant Overrides
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Override global flag settings for your organization
|
||||
</p>
|
||||
</div>
|
||||
{availableFlags.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add override form */}
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Select Flag
|
||||
</label>
|
||||
<select
|
||||
value={selectedFlagId}
|
||||
onChange={(e) => setSelectedFlagId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
<option value="">Choose a flag...</option>
|
||||
{availableFlags.map((flag) => (
|
||||
<option key={flag.id} value={flag.id}>
|
||||
{flag.name} ({flag.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Enabled
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setOverrideEnabled(!overrideEnabled)}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg border',
|
||||
overrideEnabled
|
||||
? 'bg-green-50 border-green-200 text-green-600 dark:bg-green-900/20 dark:border-green-800'
|
||||
: 'bg-secondary-50 border-secondary-200 text-secondary-400 dark:bg-secondary-700 dark:border-secondary-600'
|
||||
)}
|
||||
>
|
||||
{overrideEnabled ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdd(false)}
|
||||
className="px-3 py-2 text-secondary-600 hover:bg-secondary-200 dark:text-secondary-400 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedFlagId || isLoading}
|
||||
className="px-3 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overrides list */}
|
||||
{overrides.length === 0 ? (
|
||||
<div className="text-center py-8 text-secondary-500">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No tenant overrides configured</p>
|
||||
<p className="text-sm">All flags use their global default values</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{overrides.map((override) => {
|
||||
const flag = getFlag(override.flag_id);
|
||||
return (
|
||||
<div
|
||||
key={override.id}
|
||||
className="flex items-center justify-between p-3 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'p-1.5 rounded-lg',
|
||||
override.is_enabled
|
||||
? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-secondary-100 text-secondary-400 dark:bg-secondary-700'
|
||||
)}
|
||||
>
|
||||
{override.is_enabled ? (
|
||||
<ToggleRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ToggleLeft className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{flag?.name || 'Unknown Flag'}
|
||||
</p>
|
||||
<code className="text-xs text-secondary-500 font-mono">
|
||||
{flag?.key}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
override.is_enabled
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400'
|
||||
)}
|
||||
>
|
||||
{override.is_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(override.flag_id)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
title="Remove override"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/feature-flags/index.ts
Normal file
3
src/components/feature-flags/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './FeatureFlagCard';
|
||||
export * from './FeatureFlagForm';
|
||||
export * from './TenantOverridesPanel';
|
||||
26
src/components/index.ts
Normal file
26
src/components/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// Common
|
||||
export * from './common';
|
||||
|
||||
// Notifications
|
||||
export * from './notifications';
|
||||
|
||||
// AI
|
||||
export * from './ai';
|
||||
|
||||
// Storage
|
||||
export * from './storage';
|
||||
|
||||
// Webhooks
|
||||
export * from './webhooks';
|
||||
|
||||
// Audit
|
||||
export * from './audit';
|
||||
|
||||
// Feature Flags
|
||||
export * from './feature-flags';
|
||||
|
||||
// Auth
|
||||
export * from './auth';
|
||||
|
||||
// Analytics
|
||||
export * from './analytics';
|
||||
185
src/components/notifications/DevicesManager.tsx
Normal file
185
src/components/notifications/DevicesManager.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useDevices, usePushNotifications, UserDevice } from '@/hooks/usePushNotifications';
|
||||
|
||||
interface DevicesManagerProps {
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function DevicesManager({ showHeader = true }: DevicesManagerProps) {
|
||||
const { data: devices, isLoading, error, refetch } = useDevices();
|
||||
const { unregisterDevice, isSubscribed, requestPermission, isLoading: pushLoading } = usePushNotifications();
|
||||
|
||||
const handleUnregister = async (deviceId: string) => {
|
||||
if (confirm('Estas seguro de que deseas eliminar este dispositivo?')) {
|
||||
await unregisterDevice.mutateAsync(deviceId);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (device: UserDevice) => {
|
||||
switch (device.device_type) {
|
||||
case 'mobile':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
case 'desktop':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-red-500 text-sm">
|
||||
Error al cargar dispositivos. Por favor intenta de nuevo.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showHeader && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Dispositivos registrados
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona los dispositivos donde recibes notificaciones push
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isSubscribed && (
|
||||
<button
|
||||
onClick={() => requestPermission()}
|
||||
disabled={pushLoading}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
{pushLoading ? 'Registrando...' : 'Agregar este dispositivo'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices && devices.length === 0 ? (
|
||||
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
Sin dispositivos registrados
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Registra este dispositivo para recibir notificaciones push.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => requestPermission()}
|
||||
disabled={pushLoading}
|
||||
className="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
Registrar dispositivo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
||||
{devices?.map((device) => (
|
||||
<li
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-4 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
device.is_active
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{getDeviceIcon(device)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{device.device_name || `${device.browser} en ${device.os}`}
|
||||
</span>
|
||||
{device.is_active ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
Inactivo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Ultimo uso: {formatDate(device.last_used_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleUnregister(device.id)}
|
||||
disabled={unregisterDevice.isPending}
|
||||
className="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{devices && devices.length > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Los dispositivos inactivos se eliminan automaticamente despues de 90
|
||||
dias sin uso.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevicesManager;
|
||||
42
src/components/notifications/NotificationBell.tsx
Normal file
42
src/components/notifications/NotificationBell.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useUnreadNotificationsCount } from '@/hooks/useData';
|
||||
import { NotificationDrawer } from './NotificationDrawer';
|
||||
|
||||
export function NotificationBell() {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const { data } = useUnreadNotificationsCount();
|
||||
|
||||
const unreadCount = data?.count ?? 0;
|
||||
const hasUnread = unreadCount > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
|
||||
aria-label={`Notifications${hasUnread ? ` (${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
<Bell
|
||||
className={clsx(
|
||||
'w-5 h-5 transition-colors',
|
||||
hasUnread
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-secondary-600 dark:text-secondary-400'
|
||||
)}
|
||||
/>
|
||||
{hasUnread && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<NotificationDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/components/notifications/NotificationDrawer.tsx
Normal file
117
src/components/notifications/NotificationDrawer.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { X, Bell, CheckCheck, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useNotifications, useMarkNotificationAsRead, useMarkAllNotificationsAsRead } from '@/hooks/useData';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
interface NotificationDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useNotifications(1, 20);
|
||||
const markAsRead = useMarkNotificationAsRead();
|
||||
const markAllAsRead = useMarkAllNotificationsAsRead();
|
||||
|
||||
const notifications = data?.data ?? [];
|
||||
const hasUnread = notifications.some((n) => !n.read_at);
|
||||
|
||||
const handleMarkAsRead = (id: string) => {
|
||||
markAsRead.mutate(id);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
markAllAsRead.mutate();
|
||||
};
|
||||
|
||||
const handleNavigate = (url: string) => {
|
||||
onClose();
|
||||
navigate(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed top-0 right-0 z-50 h-full w-full sm:w-96 bg-white dark:bg-secondary-800 shadow-xl transform transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Notifications
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnread && (
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={markAllAsRead.isPending}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
{markAllAsRead.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCheck className="w-3 h-3" />
|
||||
)}
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="h-[calc(100%-4rem)] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
|
||||
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4">
|
||||
<Bell className="w-8 h-8 text-secondary-400" />
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
|
||||
No notifications
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-500 mt-1">
|
||||
You're all caught up!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-100 dark:divide-secondary-700">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onRead={handleMarkAsRead}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
src/components/notifications/NotificationItem.tsx
Normal file
83
src/components/notifications/NotificationItem.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Bell, AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import type { Notification } from '@/hooks/useData';
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
onRead: (id: string) => void;
|
||||
onNavigate?: (url: string) => void;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Bell> = {
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
warning: AlertTriangle,
|
||||
error: AlertCircle,
|
||||
default: Bell,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
info: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20',
|
||||
success: 'text-green-500 bg-green-50 dark:bg-green-900/20',
|
||||
warning: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-900/20',
|
||||
error: 'text-red-500 bg-red-50 dark:bg-red-900/20',
|
||||
default: 'text-secondary-500 bg-secondary-50 dark:bg-secondary-800',
|
||||
};
|
||||
|
||||
export function NotificationItem({ notification, onRead, onNavigate }: NotificationItemProps) {
|
||||
const isRead = !!notification.read_at;
|
||||
const Icon = typeIcons[notification.type] || typeIcons.default;
|
||||
const colorClass = typeColors[notification.type] || typeColors.default;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isRead) {
|
||||
onRead(notification.id);
|
||||
}
|
||||
// If notification has action_url, navigate
|
||||
const actionUrl = (notification as any).action_url;
|
||||
if (actionUrl && onNavigate) {
|
||||
onNavigate(actionUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg',
|
||||
isRead
|
||||
? 'bg-transparent hover:bg-secondary-50 dark:hover:bg-secondary-800'
|
||||
: 'bg-primary-50/50 dark:bg-primary-900/10 hover:bg-primary-50 dark:hover:bg-primary-900/20'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm font-medium truncate',
|
||||
isRead ? 'text-secondary-600 dark:text-secondary-400' : 'text-secondary-900 dark:text-secondary-100'
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!isRead && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 line-clamp-2 mt-0.5">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
||||
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
133
src/components/notifications/PushPermissionBanner.tsx
Normal file
133
src/components/notifications/PushPermissionBanner.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
|
||||
interface PushPermissionBannerProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function PushPermissionBanner({ onDismiss }: PushPermissionBannerProps) {
|
||||
const {
|
||||
isSupported,
|
||||
isEnabled,
|
||||
permission,
|
||||
isSubscribed,
|
||||
isLoading,
|
||||
error,
|
||||
requestPermission,
|
||||
} = usePushNotifications();
|
||||
|
||||
// Don't show if not supported, not enabled, already subscribed, or denied
|
||||
if (!isSupported || !isEnabled || isSubscribed || permission === 'denied') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEnable = async () => {
|
||||
await requestPermission();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Store dismissal in localStorage to not show again this session
|
||||
localStorage.setItem('push-banner-dismissed', 'true');
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
// Check if already dismissed
|
||||
if (localStorage.getItem('push-banner-dismissed')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
Activar notificaciones push
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-blue-600">
|
||||
Recibe notificaciones en tiempo real sobre actualizaciones
|
||||
importantes, incluso cuando no estes usando la aplicacion.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
Error: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-3">
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Activando...
|
||||
</>
|
||||
) : (
|
||||
'Activar notificaciones'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-700 hover:text-blue-800"
|
||||
>
|
||||
Ahora no
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
<span className="sr-only">Cerrar</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PushPermissionBanner;
|
||||
5
src/components/notifications/index.ts
Normal file
5
src/components/notifications/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { NotificationBell } from './NotificationBell';
|
||||
export { NotificationDrawer } from './NotificationDrawer';
|
||||
export { NotificationItem } from './NotificationItem';
|
||||
export { PushPermissionBanner } from './PushPermissionBanner';
|
||||
export { DevicesManager } from './DevicesManager';
|
||||
186
src/components/storage/FileItem.tsx
Normal file
186
src/components/storage/FileItem.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
File,
|
||||
Image,
|
||||
FileText,
|
||||
Table,
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
Lock,
|
||||
Users,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { StorageFile, storageApi } from '@/services/api';
|
||||
import { useDeleteFile } from '@/hooks/useStorage';
|
||||
|
||||
interface FileItemProps {
|
||||
file: StorageFile;
|
||||
view?: 'grid' | 'list';
|
||||
onDelete?: () => void;
|
||||
onPreview?: (file: StorageFile) => void;
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return Image;
|
||||
if (mimeType.includes('pdf') || mimeType.includes('word')) return FileText;
|
||||
if (mimeType.includes('excel') || mimeType.includes('sheet') || mimeType.includes('csv')) return Table;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function VisibilityIcon({ visibility }: { visibility: string }) {
|
||||
switch (visibility) {
|
||||
case 'public':
|
||||
return <Globe className="w-3 h-3 text-green-500" />;
|
||||
case 'tenant':
|
||||
return <Users className="w-3 h-3 text-blue-500" />;
|
||||
default:
|
||||
return <Lock className="w-3 h-3 text-gray-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileItem({ file, view = 'list', onDelete, onPreview }: FileItemProps) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const deleteMutation = useDeleteFile();
|
||||
|
||||
const FileIcon = getFileIcon(file.mimeType);
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url } = await storageApi.getDownloadUrl(file.id);
|
||||
window.open(url, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Delete "${file.originalName}"?`)) return;
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync(file.id);
|
||||
onDelete?.();
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (view === 'grid') {
|
||||
return (
|
||||
<div className="relative group bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
{/* Thumbnail or Icon */}
|
||||
<div className="aspect-square bg-gray-100 rounded-lg flex items-center justify-center mb-3 overflow-hidden">
|
||||
{isImage && file.thumbnails?.small ? (
|
||||
<img
|
||||
src={file.thumbnails.small}
|
||||
alt={file.originalName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="truncate text-sm font-medium text-gray-900" title={file.originalName}>
|
||||
{file.originalName}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
||||
<VisibilityIcon visibility={file.visibility} />
|
||||
<span>{formatBytes(file.sizeBytes)}</span>
|
||||
</div>
|
||||
|
||||
{/* Hover actions */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
|
||||
{isImage && (
|
||||
<button
|
||||
onClick={() => onPreview?.(file)}
|
||||
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-3 bg-white border rounded-lg hover:bg-gray-50">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 p-2 bg-gray-100 rounded-lg">
|
||||
<FileIcon className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="flex-grow min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-gray-900" title={file.originalName}>
|
||||
{file.originalName}
|
||||
</span>
|
||||
<VisibilityIcon visibility={file.visibility} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{formatBytes(file.sizeBytes)}</span>
|
||||
<span>{file.folder}</span>
|
||||
<span>{formatDate(file.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/components/storage/FileList.tsx
Normal file
152
src/components/storage/FileList.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid, List, Search, Folder, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useFiles } from '@/hooks/useStorage';
|
||||
import { StorageFile } from '@/services/api';
|
||||
import { FileItem } from './FileItem';
|
||||
|
||||
interface FileListProps {
|
||||
folder?: string;
|
||||
onPreview?: (file: StorageFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileList({ folder, onPreview, className = '' }: FileListProps) {
|
||||
const [view, setView] = useState<'grid' | 'list'>('list');
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const limit = 12;
|
||||
|
||||
const { data, isLoading, error, refetch } = useFiles({
|
||||
page,
|
||||
limit,
|
||||
folder,
|
||||
search: search || undefined,
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
<p>Failed to load files</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-blue-600 hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`p-2 rounded ${view === 'list' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('grid')}
|
||||
className={`p-2 rounded ${view === 'grid' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<Grid className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Folder className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-lg font-medium">No files found</p>
|
||||
<p className="text-sm mt-1">
|
||||
{search ? 'Try a different search term' : 'Upload your first file to get started'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{!isLoading && data && data.data.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
view === 'grid'
|
||||
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4'
|
||||
: 'space-y-2'
|
||||
}
|
||||
>
|
||||
{data.data.map((file) => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
view={view}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Showing {(page - 1) * limit + 1} to{' '}
|
||||
{Math.min(page * limit, data.total)} of {data.total} files
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
|
||||
disabled={page === data.totalPages}
|
||||
className="p-2 rounded border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
src/components/storage/FileUpload.tsx
Normal file
244
src/components/storage/FileUpload.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, X, File, Image, FileText, Table, AlertCircle } from 'lucide-react';
|
||||
import { useUploadFile } from '@/hooks/useStorage';
|
||||
|
||||
interface FileUploadProps {
|
||||
folder?: string;
|
||||
visibility?: 'private' | 'tenant' | 'public';
|
||||
accept?: string;
|
||||
maxSize?: number; // in bytes
|
||||
onSuccess?: (file: { id: string; filename: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return Image;
|
||||
if (mimeType.includes('pdf') || mimeType.includes('word')) return FileText;
|
||||
if (mimeType.includes('excel') || mimeType.includes('sheet') || mimeType.includes('csv')) return Table;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
folder = 'files',
|
||||
visibility = 'private',
|
||||
accept,
|
||||
maxSize = 50 * 1024 * 1024, // 50MB default
|
||||
onSuccess,
|
||||
onError,
|
||||
className = '',
|
||||
}: FileUploadProps) {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadMutation = useUploadFile();
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return 'File type not allowed';
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
return `File too large (max: ${formatBytes(maxSize)})`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
setError(null);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
}, [maxSize]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null);
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const result = await uploadMutation.mutateAsync({
|
||||
file: selectedFile,
|
||||
folder,
|
||||
visibility,
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
|
||||
onSuccess?.({ id: result.id, filename: result.filename });
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(0);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
setError(error.message);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const FileIcon = selectedFile ? getFileIcon(selectedFile.type) : File;
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-8
|
||||
transition-colors cursor-pointer
|
||||
${dragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
|
||||
${selectedFile ? 'bg-gray-50' : ''}
|
||||
`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !selectedFile && inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept || ALLOWED_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500">
|
||||
<Upload className="w-12 h-12 mb-4" />
|
||||
<p className="text-lg font-medium">
|
||||
Drop a file here or click to browse
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
Max size: {formatBytes(maxSize)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-gray-100 rounded-lg">
|
||||
<FileIcon className="w-8 h-8 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatBytes(selectedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearFile();
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 mt-3 text-red-600">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{uploadMutation.isPending && uploadProgress > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
{selectedFile && !uploadMutation.isPending && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="mt-4 w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/components/storage/StorageUsageCard.tsx
Normal file
109
src/components/storage/StorageUsageCard.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { HardDrive, Folder } from 'lucide-react';
|
||||
import { useStorageUsage } from '@/hooks/useStorage';
|
||||
|
||||
interface StorageUsageCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function StorageUsageCard({ className = '' }: StorageUsageCardProps) {
|
||||
const { data, isLoading, error } = useStorageUsage();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border p-6 ${className}`}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4" />
|
||||
<div className="h-2 bg-gray-200 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border p-6 ${className}`}>
|
||||
<p className="text-gray-500">Unable to load storage usage</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usedPercent = data.maxBytes ? data.usagePercent : 0;
|
||||
const progressColor =
|
||||
usedPercent > 90 ? 'bg-red-500' : usedPercent > 70 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border p-6 ${className}`}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<HardDrive className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Storage Usage</h3>
|
||||
</div>
|
||||
|
||||
{/* Usage stats */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">
|
||||
{formatBytes(data.totalBytes)} used
|
||||
</span>
|
||||
{data.maxBytes && (
|
||||
<span className="text-gray-500">
|
||||
of {formatBytes(data.maxBytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.maxBytes && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`${progressColor} h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min(usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files count */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Total files</span>
|
||||
<span className="font-medium">{data.totalFiles.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Max file size */}
|
||||
{data.maxFileSize && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Max file size</span>
|
||||
<span className="font-medium">{formatBytes(data.maxFileSize)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files by folder */}
|
||||
{Object.keys(data.filesByFolder).length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">By Folder</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(data.filesByFolder).map(([folder, count]) => (
|
||||
<div key={folder} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Folder className="w-4 h-4" />
|
||||
<span>{folder}</span>
|
||||
</div>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/storage/index.ts
Normal file
4
src/components/storage/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { FileUpload } from './FileUpload';
|
||||
export { FileItem } from './FileItem';
|
||||
export { FileList } from './FileList';
|
||||
export { StorageUsageCard } from './StorageUsageCard';
|
||||
213
src/components/webhooks/WebhookCard.tsx
Normal file
213
src/components/webhooks/WebhookCard.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { Webhook } from '@/services/api';
|
||||
import {
|
||||
Globe,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface WebhookCardProps {
|
||||
webhook: Webhook;
|
||||
onEdit: (webhook: Webhook) => void;
|
||||
onDelete: (webhook: Webhook) => void;
|
||||
onTest: (webhook: Webhook) => void;
|
||||
onToggle: (webhook: Webhook) => void;
|
||||
onViewDeliveries: (webhook: Webhook) => void;
|
||||
}
|
||||
|
||||
export function WebhookCard({
|
||||
webhook,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
onToggle,
|
||||
onViewDeliveries,
|
||||
}: WebhookCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const stats = webhook.stats;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'p-2 rounded-lg',
|
||||
webhook.isActive
|
||||
? 'bg-green-100 dark:bg-green-900/20'
|
||||
: 'bg-secondary-100 dark:bg-secondary-700'
|
||||
)}
|
||||
>
|
||||
<Globe
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
webhook.isActive
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-secondary-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{webhook.name}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-500 truncate max-w-xs" title={webhook.url}>
|
||||
{webhook.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5 text-secondary-400" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(webhook);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTest(webhook);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Send Test
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onViewDeliveries(webhook);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
View Deliveries
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggle(webhook);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{webhook.isActive ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<hr className="my-1 border-secondary-200 dark:border-secondary-700" />
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(webhook);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{webhook.events.slice(0, 3).map((event) => (
|
||||
<span
|
||||
key={event}
|
||||
className="px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 rounded"
|
||||
>
|
||||
{event}
|
||||
</span>
|
||||
))}
|
||||
{webhook.events.length > 3 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 rounded">
|
||||
+{webhook.events.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-1 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="font-semibold">{stats.successfulDeliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">Delivered</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-1 text-red-600 dark:text-red-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="font-semibold">{stats.failedDeliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">Failed</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-1 text-yellow-600 dark:text-yellow-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-semibold">{stats.pendingDeliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">Pending</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
{stats.successRate}%
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">Success Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
webhook.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400'
|
||||
)}
|
||||
>
|
||||
{webhook.isActive ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
{stats?.lastDeliveryAt && (
|
||||
<span className="text-xs text-secondary-500">
|
||||
Last delivery: {new Date(stats.lastDeliveryAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
src/components/webhooks/WebhookDeliveryList.tsx
Normal file
226
src/components/webhooks/WebhookDeliveryList.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { WebhookDelivery, DeliveryStatus } from '@/services/api';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface WebhookDeliveryListProps {
|
||||
deliveries: WebhookDelivery[];
|
||||
isLoading?: boolean;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
DeliveryStatus,
|
||||
{ icon: typeof CheckCircle; color: string; label: string }
|
||||
> = {
|
||||
delivered: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
label: 'Delivered',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
label: 'Failed',
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
label: 'Pending',
|
||||
},
|
||||
retrying: {
|
||||
icon: RefreshCw,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
label: 'Retrying',
|
||||
},
|
||||
};
|
||||
|
||||
function DeliveryRow({
|
||||
delivery,
|
||||
onRetry,
|
||||
}: {
|
||||
delivery: WebhookDelivery;
|
||||
onRetry: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = statusConfig[delivery.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 dark:border-secondary-700 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon className={clsx('w-5 h-5', config.color)} />
|
||||
<div>
|
||||
<div className="font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{delivery.eventType}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500">
|
||||
{new Date(delivery.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{delivery.responseStatus && (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 text-xs font-medium rounded',
|
||||
delivery.responseStatus >= 200 && delivery.responseStatus < 300
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delivery.responseStatus}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 text-xs font-medium rounded',
|
||||
delivery.status === 'delivered'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: delivery.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-secondary-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-700 p-3 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div className="grid grid-cols-2 gap-4 mb-3 text-sm">
|
||||
<div>
|
||||
<span className="text-secondary-500">Attempt:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">
|
||||
{delivery.attempt} / {delivery.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
{delivery.deliveredAt && (
|
||||
<div>
|
||||
<span className="text-secondary-500">Delivered:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">
|
||||
{new Date(delivery.deliveredAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{delivery.nextRetryAt && (
|
||||
<div>
|
||||
<span className="text-secondary-500">Next Retry:</span>{' '}
|
||||
<span className="text-secondary-900 dark:text-secondary-100">
|
||||
{new Date(delivery.nextRetryAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{delivery.lastError && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
|
||||
Error:
|
||||
</div>
|
||||
<div className="text-sm text-secondary-700 dark:text-secondary-300 bg-red-50 dark:bg-red-900/20 p-2 rounded">
|
||||
{delivery.lastError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Payload:
|
||||
</div>
|
||||
<pre className="text-xs bg-secondary-100 dark:bg-secondary-800 p-2 rounded overflow-x-auto max-h-40">
|
||||
{JSON.stringify(delivery.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{delivery.responseBody && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Response:
|
||||
</div>
|
||||
<pre className="text-xs bg-secondary-100 dark:bg-secondary-800 p-2 rounded overflow-x-auto max-h-40">
|
||||
{delivery.responseBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.status === 'failed' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetry(delivery.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookDeliveryList({
|
||||
deliveries,
|
||||
isLoading,
|
||||
onRetry,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
}: WebhookDeliveryListProps) {
|
||||
if (isLoading && deliveries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-secondary-500">
|
||||
Loading deliveries...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-secondary-500">
|
||||
No deliveries yet. Send a test webhook to see delivery history.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{deliveries.map((delivery) => (
|
||||
<DeliveryRow key={delivery.id} delivery={delivery} onRetry={onRetry} />
|
||||
))}
|
||||
|
||||
{hasMore && onLoadMore && (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
src/components/webhooks/WebhookForm.tsx
Normal file
285
src/components/webhooks/WebhookForm.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import { Webhook, CreateWebhookRequest, UpdateWebhookRequest, WebhookEvent } from '@/services/api';
|
||||
import { Plus, Trash2, Eye, EyeOff, Copy, Check } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface WebhookFormProps {
|
||||
webhook?: Webhook | null;
|
||||
events: WebhookEvent[];
|
||||
onSubmit: (data: CreateWebhookRequest | UpdateWebhookRequest) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function WebhookForm({
|
||||
webhook,
|
||||
events,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: WebhookFormProps) {
|
||||
const [name, setName] = useState(webhook?.name || '');
|
||||
const [description, setDescription] = useState(webhook?.description || '');
|
||||
const [url, setUrl] = useState(webhook?.url || '');
|
||||
const [selectedEvents, setSelectedEvents] = useState<string[]>(webhook?.events || []);
|
||||
const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
|
||||
webhook?.headers
|
||||
? Object.entries(webhook.headers).map(([key, value]) => ({ key, value }))
|
||||
: []
|
||||
);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
|
||||
const isEditing = !!webhook;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const headersObj = headers.reduce((acc, { key, value }) => {
|
||||
if (key.trim()) {
|
||||
acc[key.trim()] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const data = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
url,
|
||||
events: selectedEvents,
|
||||
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
|
||||
};
|
||||
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const toggleEvent = (eventName: string) => {
|
||||
setSelectedEvents((prev) =>
|
||||
prev.includes(eventName)
|
||||
? prev.filter((e) => e !== eventName)
|
||||
: [...prev, eventName]
|
||||
);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setHeaders((prev) => [...prev, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
setHeaders((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
||||
setHeaders((prev) =>
|
||||
prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))
|
||||
);
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
if (webhook?.secret) {
|
||||
await navigator.clipboard.writeText(webhook.secret);
|
||||
setSecretCopied(true);
|
||||
setTimeout(() => setSecretCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = name.trim() && url.trim() && url.startsWith('https://') && selectedEvents.length > 0;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Webhook"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Endpoint URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
{url && !url.startsWith('https://') && (
|
||||
<p className="mt-1 text-sm text-red-600">URL must use HTTPS</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secret (only for editing) */}
|
||||
{isEditing && webhook?.secret && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Signing Secret
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={webhook.secret}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 pr-20 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-secondary-100 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySecret}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
{secretCopied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500">
|
||||
Use this secret to verify webhook signatures
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Events * ({selectedEvents.length} selected)
|
||||
</label>
|
||||
<div className="border border-secondary-300 dark:border-secondary-600 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<label
|
||||
key={event.name}
|
||||
className="flex items-start gap-3 p-2 rounded hover:bg-secondary-50 dark:hover:bg-secondary-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.includes(event.name)}
|
||||
onChange={() => toggleEvent(event.name)}
|
||||
className="mt-0.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{event.name}
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500">{event.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedEvents.length === 0 && (
|
||||
<p className="mt-1 text-sm text-red-600">Select at least one event</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Custom Headers
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeader}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Header
|
||||
</button>
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||
placeholder="Header name"
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{headers.length === 0 && (
|
||||
<p className="text-sm text-secondary-500">No custom headers</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium',
|
||||
isValid && !isLoading
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Saving...' : isEditing ? 'Update Webhook' : 'Create Webhook'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
3
src/components/webhooks/index.ts
Normal file
3
src/components/webhooks/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './WebhookCard';
|
||||
export * from './WebhookForm';
|
||||
export * from './WebhookDeliveryList';
|
||||
95
src/components/whatsapp/WhatsAppTestMessage.tsx
Normal file
95
src/components/whatsapp/WhatsAppTestMessage.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTestWhatsAppConnection } from '../../hooks/useWhatsApp';
|
||||
|
||||
interface WhatsAppTestMessageProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function WhatsAppTestMessage({ disabled }: WhatsAppTestMessageProps) {
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const testConnection = useTestWhatsAppConnection();
|
||||
|
||||
const handleTest = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (phoneNumber) {
|
||||
testConnection.mutate(phoneNumber);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Probar Conexion
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Envia un mensaje de prueba para verificar que la integracion funciona correctamente.
|
||||
</p>
|
||||
<form onSubmit={handleTest} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="testPhone"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Numero de telefono (E.164)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="testPhone"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="+521234567890"
|
||||
pattern="^\+[1-9]\d{1,14}$"
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
disabled={disabled || testConnection.isPending}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Formato E.164: +[codigo pais][numero]
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !phoneNumber || testConnection.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{testConnection.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="-ml-1 mr-2 h-4 w-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" />
|
||||
</svg>
|
||||
Enviar mensaje de prueba
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/whatsapp/index.ts
Normal file
1
src/components/whatsapp/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './WhatsAppTestMessage';
|
||||
15
src/hooks/index.ts
Normal file
15
src/hooks/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export * from './useAuth';
|
||||
export * from './useData';
|
||||
export * from './useSuperadmin';
|
||||
export * from './useOnboarding';
|
||||
export * from './useAI';
|
||||
export * from './useStorage';
|
||||
export * from './useWebhooks';
|
||||
export * from './useAudit';
|
||||
export * from './useFeatureFlags';
|
||||
export * from './usePushNotifications';
|
||||
export * from './useOAuth';
|
||||
export * from './useExport';
|
||||
export * from './useAnalytics';
|
||||
export * from './useMfa';
|
||||
export * from './useWhatsApp';
|
||||
134
src/hooks/useAI.ts
Normal file
134
src/hooks/useAI.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { aiApi, ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats } from '@/services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
|
||||
export const aiQueryKeys = {
|
||||
all: ['ai'] as const,
|
||||
config: () => [...aiQueryKeys.all, 'config'] as const,
|
||||
models: () => [...aiQueryKeys.all, 'models'] as const,
|
||||
usage: (page?: number, limit?: number) => [...aiQueryKeys.all, 'usage', { page, limit }] as const,
|
||||
currentUsage: () => [...aiQueryKeys.all, 'current-usage'] as const,
|
||||
health: () => [...aiQueryKeys.all, 'health'] as const,
|
||||
};
|
||||
|
||||
// ==================== Config Hooks ====================
|
||||
|
||||
export function useAIConfig() {
|
||||
return useQuery({
|
||||
queryKey: aiQueryKeys.config(),
|
||||
queryFn: () => aiApi.getConfig(),
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAIConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<AIConfig>) => aiApi.updateConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: aiQueryKeys.config() });
|
||||
toast.success('AI configuration updated');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update AI configuration');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Models Hook ====================
|
||||
|
||||
export function useAIModels() {
|
||||
return useQuery({
|
||||
queryKey: aiQueryKeys.models(),
|
||||
queryFn: () => aiApi.getModels(),
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes - models don't change often
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Chat Hook ====================
|
||||
|
||||
export function useAIChat() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: ChatRequest) => aiApi.chat(data),
|
||||
onSuccess: () => {
|
||||
// Invalidate usage queries after successful chat
|
||||
queryClient.invalidateQueries({ queryKey: aiQueryKeys.currentUsage() });
|
||||
queryClient.invalidateQueries({ queryKey: aiQueryKeys.usage() });
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to get AI response';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Usage Hooks ====================
|
||||
|
||||
export interface AIUsageRecord {
|
||||
id: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost: number;
|
||||
latency_ms: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AIUsageResponse {
|
||||
data: AIUsageRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export function useAIUsage(page = 1, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: aiQueryKeys.usage(page, limit),
|
||||
queryFn: () => aiApi.getUsage({ page, limit }) as Promise<AIUsageResponse>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentAIUsage() {
|
||||
return useQuery({
|
||||
queryKey: aiQueryKeys.currentUsage(),
|
||||
queryFn: () => aiApi.getCurrentUsage(),
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Health Hook ====================
|
||||
|
||||
export interface AIHealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
provider: string;
|
||||
latency_ms: number;
|
||||
models_available: number;
|
||||
}
|
||||
|
||||
export function useAIHealth() {
|
||||
return useQuery({
|
||||
queryKey: aiQueryKeys.health(),
|
||||
queryFn: () => aiApi.getHealth() as Promise<AIHealthStatus>,
|
||||
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Re-export types ====================
|
||||
|
||||
export type { ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats };
|
||||
192
src/hooks/useAnalytics.ts
Normal file
192
src/hooks/useAnalytics.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
export const analyticsKeys = {
|
||||
all: ['analytics'] as const,
|
||||
users: (period: string) => [...analyticsKeys.all, 'users', period] as const,
|
||||
billing: (period: string) => [...analyticsKeys.all, 'billing', period] as const,
|
||||
usage: (period: string) => [...analyticsKeys.all, 'usage', period] as const,
|
||||
summary: () => [...analyticsKeys.all, 'summary'] as const,
|
||||
trends: (period: string) => [...analyticsKeys.all, 'trends', period] as const,
|
||||
};
|
||||
|
||||
// Types
|
||||
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '1y';
|
||||
|
||||
export interface UserMetrics {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
churnedUsers: number;
|
||||
growthRate: number;
|
||||
activeRate: number;
|
||||
byDay: { date: string; total: number; active: number; new: number }[];
|
||||
}
|
||||
|
||||
export interface BillingMetrics {
|
||||
mrr: number;
|
||||
arr: number;
|
||||
totalRevenue: number;
|
||||
avgRevenuePerUser: number;
|
||||
mrrGrowth: number;
|
||||
churnRate: number;
|
||||
ltv: number;
|
||||
byDay: { date: string; revenue: number; mrr: number }[];
|
||||
}
|
||||
|
||||
export interface UsageMetrics {
|
||||
totalActions: number;
|
||||
avgActionsPerUser: number;
|
||||
topFeatures: { feature: string; count: number; percentage: number }[];
|
||||
byDay: { date: string; actions: number }[];
|
||||
byHour: { hour: number; actions: number }[];
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
change: number;
|
||||
};
|
||||
revenue: {
|
||||
mrr: number;
|
||||
arr: number;
|
||||
change: number;
|
||||
};
|
||||
usage: {
|
||||
actions: number;
|
||||
avgPerUser: number;
|
||||
change: number;
|
||||
};
|
||||
engagement: {
|
||||
rate: number;
|
||||
sessions: number;
|
||||
change: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsTrends {
|
||||
users: { date: string; value: number }[];
|
||||
revenue: { date: string; value: number }[];
|
||||
actions: { date: string; value: number }[];
|
||||
}
|
||||
|
||||
// Hooks
|
||||
|
||||
/**
|
||||
* Hook to fetch user metrics for a given period
|
||||
*/
|
||||
export function useUserMetrics(period: AnalyticsPeriod = '30d') {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.users(period),
|
||||
queryFn: () => analyticsApi.getUserMetrics(period),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch billing metrics for a given period
|
||||
*/
|
||||
export function useBillingMetrics(period: AnalyticsPeriod = '30d') {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.billing(period),
|
||||
queryFn: () => analyticsApi.getBillingMetrics(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch usage metrics for a given period
|
||||
*/
|
||||
export function useUsageMetrics(period: AnalyticsPeriod = '30d') {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.usage(period),
|
||||
queryFn: () => analyticsApi.getUsageMetrics(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch analytics summary (KPIs)
|
||||
*/
|
||||
export function useAnalyticsSummary() {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.summary(),
|
||||
queryFn: () => analyticsApi.getSummary(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch analytics trends for charts
|
||||
*/
|
||||
export function useAnalyticsTrends(period: AnalyticsPeriod = '30d') {
|
||||
return useQuery({
|
||||
queryKey: analyticsKeys.trends(period),
|
||||
queryFn: () => analyticsApi.getTrends(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
/**
|
||||
* Get period label for display
|
||||
*/
|
||||
export function getPeriodLabel(period: AnalyticsPeriod): string {
|
||||
const labels: Record<AnalyticsPeriod, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
'1y': 'Last year',
|
||||
};
|
||||
return labels[period];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available periods for selector
|
||||
*/
|
||||
export function getAvailablePeriods(): { value: AnalyticsPeriod; label: string }[] {
|
||||
return [
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: '90d', label: 'Last 90 days' },
|
||||
{ value: '1y', label: 'Last year' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency value
|
||||
*/
|
||||
export function formatCurrency(value: number, compact = false): string {
|
||||
if (compact && value >= 1000) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers compactly
|
||||
*/
|
||||
export function formatNumber(value: number, compact = false): string {
|
||||
if (compact && value >= 1000) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US').format(value);
|
||||
}
|
||||
154
src/hooks/useAudit.ts
Normal file
154
src/hooks/useAudit.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
auditApi,
|
||||
QueryAuditLogsParams,
|
||||
QueryActivityLogsParams,
|
||||
CreateActivityLogRequest,
|
||||
AuditAction,
|
||||
ActivityType,
|
||||
} from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
const auditKeys = {
|
||||
all: ['audit'] as const,
|
||||
logs: () => [...auditKeys.all, 'logs'] as const,
|
||||
logsQuery: (params?: QueryAuditLogsParams) => [...auditKeys.logs(), params] as const,
|
||||
log: (id: string) => [...auditKeys.logs(), id] as const,
|
||||
entityHistory: (entityType: string, entityId: string) =>
|
||||
[...auditKeys.logs(), 'entity', entityType, entityId] as const,
|
||||
stats: (days?: number) => [...auditKeys.all, 'stats', days] as const,
|
||||
activities: () => [...auditKeys.all, 'activities'] as const,
|
||||
activitiesQuery: (params?: QueryActivityLogsParams) => [...auditKeys.activities(), params] as const,
|
||||
activitySummary: (days?: number) => [...auditKeys.activities(), 'summary', days] as const,
|
||||
userActivitySummary: (userId: string, days?: number) =>
|
||||
[...auditKeys.activities(), 'user', userId, days] as const,
|
||||
};
|
||||
|
||||
// ==================== Audit Logs ====================
|
||||
|
||||
export function useAuditLogs(params?: QueryAuditLogsParams) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.logsQuery(params),
|
||||
queryFn: () => auditApi.queryLogs(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLog(id: string) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.log(id),
|
||||
queryFn: () => auditApi.getLog(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEntityAuditHistory(entityType: string, entityId: string) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.entityHistory(entityType, entityId),
|
||||
queryFn: () => auditApi.getEntityHistory(entityType, entityId),
|
||||
enabled: !!entityType && !!entityId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditStats(days?: number) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.stats(days),
|
||||
queryFn: () => auditApi.getStats(days),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Activity Logs ====================
|
||||
|
||||
export function useActivityLogs(params?: QueryActivityLogsParams) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.activitiesQuery(params),
|
||||
queryFn: () => auditApi.queryActivities(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateActivityLog() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateActivityLogRequest) => auditApi.createActivity(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: auditKeys.activities() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivitySummary(days?: number) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.activitySummary(days),
|
||||
queryFn: () => auditApi.getActivitySummary(days),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserActivitySummary(userId: string, days?: number) {
|
||||
return useQuery({
|
||||
queryKey: auditKeys.userActivitySummary(userId, days),
|
||||
queryFn: () => auditApi.getUserActivitySummary(userId, days),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Helper functions ====================
|
||||
|
||||
export function getAuditActionLabel(action: AuditAction): string {
|
||||
const labels: Record<AuditAction, string> = {
|
||||
create: 'Created',
|
||||
update: 'Updated',
|
||||
delete: 'Deleted',
|
||||
read: 'Viewed',
|
||||
login: 'Logged in',
|
||||
logout: 'Logged out',
|
||||
export: 'Exported',
|
||||
import: 'Imported',
|
||||
};
|
||||
return labels[action] || action;
|
||||
}
|
||||
|
||||
export function getAuditActionColor(action: AuditAction): string {
|
||||
const colors: Record<AuditAction, string> = {
|
||||
create: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
update: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
delete: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
read: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
|
||||
login: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
logout: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
|
||||
export: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/20 dark:text-cyan-400',
|
||||
import: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400',
|
||||
};
|
||||
return colors[action] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
export function getActivityTypeLabel(type: ActivityType): string {
|
||||
const labels: Record<ActivityType, string> = {
|
||||
page_view: 'Page View',
|
||||
feature_use: 'Feature Use',
|
||||
search: 'Search',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
share: 'Share',
|
||||
invite: 'Invite',
|
||||
settings_change: 'Settings Change',
|
||||
subscription_change: 'Subscription Change',
|
||||
payment: 'Payment',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
export function getActivityTypeIcon(type: ActivityType): string {
|
||||
const icons: Record<ActivityType, string> = {
|
||||
page_view: 'Eye',
|
||||
feature_use: 'Zap',
|
||||
search: 'Search',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
share: 'Share2',
|
||||
invite: 'UserPlus',
|
||||
settings_change: 'Settings',
|
||||
subscription_change: 'CreditCard',
|
||||
payment: 'DollarSign',
|
||||
};
|
||||
return icons[type] || 'Activity';
|
||||
}
|
||||
193
src/hooks/useAuth.ts
Normal file
193
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi, AuthResponse } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// Query keys
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
me: () => [...authKeys.all, 'me'] as const,
|
||||
};
|
||||
|
||||
// Get current user hook
|
||||
export function useCurrentUser() {
|
||||
const { isAuthenticated, accessToken } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: authKeys.me(),
|
||||
queryFn: authApi.me,
|
||||
enabled: isAuthenticated && !!accessToken,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Login mutation hook
|
||||
export function useLogin() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||
authApi.login(email, password),
|
||||
onSuccess: (data: AuthResponse) => {
|
||||
login(
|
||||
{
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
first_name: data.user.first_name || '',
|
||||
last_name: data.user.last_name || '',
|
||||
role: 'user',
|
||||
tenant_id: data.user.tenant_id,
|
||||
},
|
||||
data.accessToken,
|
||||
data.refreshToken
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.all });
|
||||
toast.success('Welcome back!');
|
||||
navigate('/dashboard');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Invalid credentials';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Register mutation hook
|
||||
export function useRegister() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
}) => authApi.register(data),
|
||||
onSuccess: (data: AuthResponse) => {
|
||||
login(
|
||||
{
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
first_name: data.user.first_name || '',
|
||||
last_name: data.user.last_name || '',
|
||||
role: 'user',
|
||||
tenant_id: data.user.tenant_id,
|
||||
},
|
||||
data.accessToken,
|
||||
data.refreshToken
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.all });
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/dashboard');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Registration failed';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Logout mutation hook
|
||||
export function useLogout() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.logout(),
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
queryClient.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
},
|
||||
onError: () => {
|
||||
// Even if API fails, still logout locally
|
||||
logout();
|
||||
queryClient.clear();
|
||||
navigate('/auth/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Request password reset mutation hook
|
||||
export function useRequestPasswordReset() {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) => authApi.requestPasswordReset(email),
|
||||
onSuccess: () => {
|
||||
toast.success('If an account exists with this email, you will receive reset instructions.');
|
||||
},
|
||||
onError: () => {
|
||||
// Don't reveal if email exists or not
|
||||
toast.success('If an account exists with this email, you will receive reset instructions.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Reset password mutation hook
|
||||
export function useResetPassword() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ token, password }: { token: string; password: string }) =>
|
||||
authApi.resetPassword(token, password),
|
||||
onSuccess: () => {
|
||||
toast.success('Password reset successfully! Please login with your new password.');
|
||||
navigate('/auth/login');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Invalid or expired reset token';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Change password mutation hook
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}) => authApi.changePassword(currentPassword, newPassword),
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to change password';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Verify email mutation hook
|
||||
export function useVerifyEmail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (token: string) => authApi.verifyEmail(token),
|
||||
onSuccess: () => {
|
||||
toast.success('Email verified successfully!');
|
||||
navigate('/dashboard');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Invalid or expired verification token';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
364
src/hooks/useData.ts
Normal file
364
src/hooks/useData.ts
Normal file
@ -0,0 +1,364 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { usersApi, billingApi, stripeApi, notificationsApi } from '@/services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
|
||||
export const queryKeys = {
|
||||
users: {
|
||||
all: ['users'] as const,
|
||||
list: (page?: number, limit?: number) => [...queryKeys.users.all, 'list', { page, limit }] as const,
|
||||
detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const,
|
||||
},
|
||||
billing: {
|
||||
all: ['billing'] as const,
|
||||
subscription: () => [...queryKeys.billing.all, 'subscription'] as const,
|
||||
subscriptionStatus: () => [...queryKeys.billing.all, 'subscription-status'] as const,
|
||||
invoices: (page?: number, limit?: number) => [...queryKeys.billing.all, 'invoices', { page, limit }] as const,
|
||||
invoice: (id: string) => [...queryKeys.billing.all, 'invoice', id] as const,
|
||||
paymentMethods: () => [...queryKeys.billing.all, 'payment-methods'] as const,
|
||||
summary: () => [...queryKeys.billing.all, 'summary'] as const,
|
||||
},
|
||||
stripe: {
|
||||
all: ['stripe'] as const,
|
||||
customer: () => [...queryKeys.stripe.all, 'customer'] as const,
|
||||
prices: () => [...queryKeys.stripe.all, 'prices'] as const,
|
||||
paymentMethods: () => [...queryKeys.stripe.all, 'payment-methods'] as const,
|
||||
},
|
||||
notifications: {
|
||||
all: ['notifications'] as const,
|
||||
list: (page?: number, limit?: number) => [...queryKeys.notifications.all, 'list', { page, limit }] as const,
|
||||
unreadCount: () => [...queryKeys.notifications.all, 'unread-count'] as const,
|
||||
preferences: () => [...queryKeys.notifications.all, 'preferences'] as const,
|
||||
},
|
||||
dashboard: {
|
||||
all: ['dashboard'] as const,
|
||||
stats: () => [...queryKeys.dashboard.all, 'stats'] as const,
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Users Hooks ====================
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
roles?: { name: string }[];
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
data: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export function useUsers(page = 1, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.users.list(page, limit),
|
||||
queryFn: () => usersApi.list({ page, limit }) as Promise<UsersResponse>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.users.detail(id),
|
||||
queryFn: () => usersApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInviteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ email, role }: { email: string; role?: string }) =>
|
||||
usersApi.invite(email, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
toast.success('Invitation sent successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to send invitation');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<{ first_name: string; last_name: string }> }) =>
|
||||
usersApi.update(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(variables.id) });
|
||||
toast.success('User updated successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update user');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Billing Hooks ====================
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
cancel_at_period_end: boolean;
|
||||
stripe_subscription_id?: string;
|
||||
plan?: {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
billing_cycle: string;
|
||||
features?: { name: string; included: boolean }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
issue_date: string;
|
||||
due_date: string;
|
||||
paid_at: string | null;
|
||||
stripe_invoice_id?: string;
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
type: string;
|
||||
card_last_four: string;
|
||||
card_brand: string;
|
||||
card_exp_month: number;
|
||||
card_exp_year: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface BillingSummary {
|
||||
subscription: Subscription | null;
|
||||
totalPaid: number;
|
||||
invoiceCount: number;
|
||||
upcomingInvoice: {
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useSubscription() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.subscription(),
|
||||
queryFn: () => billingApi.getSubscription() as Promise<Subscription | null>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubscriptionStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.subscriptionStatus(),
|
||||
queryFn: () => billingApi.getSubscriptionStatus(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoices(page = 1, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoices(page, limit),
|
||||
queryFn: () => billingApi.getInvoices({ page, limit }) as Promise<{ data: Invoice[]; total: number }>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoice(id: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoice(id),
|
||||
queryFn: () => billingApi.getInvoice(id) as Promise<Invoice>,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentMethods() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.paymentMethods(),
|
||||
queryFn: () => billingApi.getPaymentMethods() as Promise<PaymentMethod[]>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBillingSummary() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.summary(),
|
||||
queryFn: () => billingApi.getSummary() as Promise<BillingSummary>,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Stripe Hooks ====================
|
||||
|
||||
export function useStripeCustomer() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.stripe.customer(),
|
||||
queryFn: stripeApi.getCustomer,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStripePrices() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.stripe.prices(),
|
||||
queryFn: stripeApi.getPrices,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCheckoutSession() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { price_id: string; success_url: string; cancel_url: string }) =>
|
||||
stripeApi.createCheckoutSession(data),
|
||||
onSuccess: (data) => {
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to create checkout session');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBillingPortal() {
|
||||
return useMutation({
|
||||
mutationFn: (returnUrl: string) => stripeApi.createBillingPortal(returnUrl),
|
||||
onSuccess: (data) => {
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to open billing portal');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Notifications Hooks ====================
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: string;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useNotifications(page = 1, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.notifications.list(page, limit),
|
||||
queryFn: () => notificationsApi.list({ page, limit }) as Promise<{ data: Notification[]; total: number }>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadNotificationsCount() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.notifications.unreadCount(),
|
||||
queryFn: () => notificationsApi.getUnreadCount() as Promise<{ count: number }>,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationAsRead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markAsRead(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsAsRead() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => notificationsApi.markAllAsRead(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||
toast.success('All notifications marked as read');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationPreferences() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.notifications.preferences(),
|
||||
queryFn: notificationsApi.getPreferences,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotificationPreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (preferences: Record<string, boolean>) =>
|
||||
notificationsApi.updatePreferences(preferences),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.preferences() });
|
||||
toast.success('Preferences updated');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update preferences');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Dashboard Stats Hook ====================
|
||||
|
||||
export interface DashboardStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
monthlyRevenue: number;
|
||||
revenueChange: number;
|
||||
activeSessions: number;
|
||||
sessionsChange: number;
|
||||
planName: string;
|
||||
}
|
||||
|
||||
export function useDashboardStats() {
|
||||
const { data: users } = useUsers(1, 1);
|
||||
const { data: subscription } = useSubscription();
|
||||
const { data: billingSummary } = useBillingSummary();
|
||||
|
||||
// Compute dashboard stats from available data
|
||||
const stats: DashboardStats = {
|
||||
totalUsers: users?.total ?? 0,
|
||||
activeUsers: users?.total ?? 0, // In real app, filter by status
|
||||
monthlyRevenue: billingSummary?.totalPaid ?? 0,
|
||||
revenueChange: 0, // Would need historical data
|
||||
activeSessions: 0, // Would need session tracking
|
||||
sessionsChange: 0,
|
||||
planName: subscription?.plan?.display_name ?? 'Free',
|
||||
};
|
||||
|
||||
return {
|
||||
data: stats,
|
||||
isLoading: !users && !subscription,
|
||||
};
|
||||
}
|
||||
139
src/hooks/useExport.ts
Normal file
139
src/hooks/useExport.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { reportsApi } from '@/services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export type ReportType = 'users' | 'billing' | 'audit';
|
||||
export type ExportFormat = 'pdf' | 'excel' | 'csv';
|
||||
|
||||
export interface ExportParams {
|
||||
reportType: ReportType;
|
||||
format: ExportFormat;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
// File extension mapping
|
||||
const formatExtensions: Record<ExportFormat, string> = {
|
||||
pdf: 'pdf',
|
||||
excel: 'xlsx',
|
||||
csv: 'csv',
|
||||
};
|
||||
|
||||
// Generate filename with timestamp
|
||||
function generateFilename(reportType: ReportType, format: ExportFormat): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const extension = formatExtensions[format];
|
||||
return `${reportType}-report-${timestamp}.${extension}`;
|
||||
}
|
||||
|
||||
// Download blob as file
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Export function based on report type
|
||||
async function exportReportData(params: ExportParams): Promise<Blob> {
|
||||
const { reportType, format, dateFrom, dateTo } = params;
|
||||
const queryParams = { dateFrom, dateTo };
|
||||
|
||||
let response;
|
||||
switch (reportType) {
|
||||
case 'users':
|
||||
response = await reportsApi.exportUsers(format, queryParams);
|
||||
break;
|
||||
case 'billing':
|
||||
response = await reportsApi.exportBilling(format, queryParams);
|
||||
break;
|
||||
case 'audit':
|
||||
response = await reportsApi.exportAudit(format, queryParams);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown report type: ${reportType}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for exporting reports
|
||||
* Handles the download of files in PDF, Excel, or CSV format
|
||||
*/
|
||||
export function useExportReport() {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exportReportData,
|
||||
onMutate: () => {
|
||||
setIsExporting(true);
|
||||
},
|
||||
onSuccess: (blob, params) => {
|
||||
const filename = generateFilename(params.reportType, params.format);
|
||||
downloadBlob(blob, filename);
|
||||
toast.success(`${params.reportType.charAt(0).toUpperCase() + params.reportType.slice(1)} report exported successfully!`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Export error:', error);
|
||||
toast.error(error.message || 'Failed to export report. Please try again.');
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsExporting(false);
|
||||
},
|
||||
});
|
||||
|
||||
const exportReport = async (params: ExportParams) => {
|
||||
return mutation.mutateAsync(params);
|
||||
};
|
||||
|
||||
return {
|
||||
exportReport,
|
||||
isExporting,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for exporting reports with modal integration
|
||||
* Provides additional state for modal open/close
|
||||
*/
|
||||
export function useExportModal(reportType: ReportType) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { exportReport, isExporting, error } = useExportReport();
|
||||
|
||||
const openModal = () => setIsModalOpen(true);
|
||||
const closeModal = () => {
|
||||
if (!isExporting) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (params: { format: ExportFormat; dateFrom?: string; dateTo?: string }) => {
|
||||
try {
|
||||
await exportReport({
|
||||
reportType,
|
||||
format: params.format,
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
});
|
||||
closeModal();
|
||||
} catch {
|
||||
// Error is handled by the mutation
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isModalOpen,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleExport,
|
||||
isExporting,
|
||||
error,
|
||||
};
|
||||
}
|
||||
225
src/hooks/useFeatureFlags.ts
Normal file
225
src/hooks/useFeatureFlags.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
featureFlagsApi,
|
||||
CreateFlagRequest,
|
||||
UpdateFlagRequest,
|
||||
SetTenantFlagRequest,
|
||||
SetUserFlagRequest,
|
||||
FlagType,
|
||||
FlagScope,
|
||||
} from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
const flagKeys = {
|
||||
all: ['feature-flags'] as const,
|
||||
list: () => [...flagKeys.all, 'list'] as const,
|
||||
detail: (id: string) => [...flagKeys.all, 'detail', id] as const,
|
||||
tenantOverrides: () => [...flagKeys.all, 'tenant-overrides'] as const,
|
||||
userOverrides: (userId: string) => [...flagKeys.all, 'user-overrides', userId] as const,
|
||||
evaluation: () => [...flagKeys.all, 'evaluation'] as const,
|
||||
evaluationKey: (key: string) => [...flagKeys.evaluation(), key] as const,
|
||||
check: (key: string) => [...flagKeys.all, 'check', key] as const,
|
||||
};
|
||||
|
||||
// ==================== Flag Management ====================
|
||||
|
||||
export function useFeatureFlags() {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.list(),
|
||||
queryFn: () => featureFlagsApi.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFeatureFlag(id: string) {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.detail(id),
|
||||
queryFn: () => featureFlagsApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFeatureFlag() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateFlagRequest) => featureFlagsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFeatureFlag() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateFlagRequest }) =>
|
||||
featureFlagsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFeatureFlag() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => featureFlagsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleFeatureFlag() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||
featureFlagsApi.toggle(id, enabled),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Tenant Flags ====================
|
||||
|
||||
export function useTenantFlagOverrides() {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.tenantOverrides(),
|
||||
queryFn: () => featureFlagsApi.getTenantOverrides(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetTenantFlagOverride() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: SetTenantFlagRequest) => featureFlagsApi.setTenantOverride(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.tenantOverrides() });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveTenantFlagOverride() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (flagId: string) => featureFlagsApi.removeTenantOverride(flagId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.tenantOverrides() });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== User Flags ====================
|
||||
|
||||
export function useUserFlagOverrides(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.userOverrides(userId),
|
||||
queryFn: () => featureFlagsApi.getUserOverrides(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetUserFlagOverride() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: SetUserFlagRequest) => featureFlagsApi.setUserOverride(data),
|
||||
onSuccess: (_, { user_id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.userOverrides(user_id) });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveUserFlagOverride() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, flagId }: { userId: string; flagId: string }) =>
|
||||
featureFlagsApi.removeUserOverride(userId, flagId),
|
||||
onSuccess: (_, { userId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.userOverrides(userId) });
|
||||
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Evaluation ====================
|
||||
|
||||
export function useFlagEvaluation(key: string) {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.evaluationKey(key),
|
||||
queryFn: () => featureFlagsApi.evaluate(key),
|
||||
enabled: !!key,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllFlagEvaluations() {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.evaluation(),
|
||||
queryFn: () => featureFlagsApi.evaluateAll(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFlagCheck(key: string) {
|
||||
return useQuery({
|
||||
queryKey: flagKeys.check(key),
|
||||
queryFn: () => featureFlagsApi.check(key),
|
||||
enabled: !!key,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Helper functions ====================
|
||||
|
||||
export function getFlagTypeLabel(type: FlagType): string {
|
||||
const labels: Record<FlagType, string> = {
|
||||
boolean: 'Boolean',
|
||||
string: 'String',
|
||||
number: 'Number',
|
||||
json: 'JSON',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
export function getFlagTypeColor(type: FlagType): string {
|
||||
const colors: Record<FlagType, string> = {
|
||||
boolean: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
string: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
number: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
json: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
export function getFlagScopeLabel(scope: FlagScope): string {
|
||||
const labels: Record<FlagScope, string> = {
|
||||
global: 'Global',
|
||||
tenant: 'Tenant',
|
||||
user: 'User',
|
||||
plan: 'Plan',
|
||||
};
|
||||
return labels[scope] || scope;
|
||||
}
|
||||
|
||||
export function getFlagScopeColor(scope: FlagScope): string {
|
||||
const colors: Record<FlagScope, string> = {
|
||||
global: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
|
||||
tenant: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
user: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
plan: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400',
|
||||
};
|
||||
return colors[scope] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
149
src/hooks/useMfa.ts
Normal file
149
src/hooks/useMfa.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import api from '@/services/api';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface MfaStatus {
|
||||
enabled: boolean;
|
||||
enabledAt?: string;
|
||||
backupCodesRemaining: number;
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
secret: string;
|
||||
qrCodeDataUrl: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface VerifyMfaSetupDto {
|
||||
code: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface DisableMfaDto {
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegenerateBackupCodesDto {
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface BackupCodesResponse {
|
||||
backupCodes: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Query keys
|
||||
export const mfaKeys = {
|
||||
all: ['mfa'] as const,
|
||||
status: () => [...mfaKeys.all, 'status'] as const,
|
||||
};
|
||||
|
||||
// API functions
|
||||
const mfaApi = {
|
||||
getStatus: (): Promise<MfaStatus> =>
|
||||
api.get('/auth/mfa/status').then((res) => res.data),
|
||||
|
||||
setup: (): Promise<MfaSetupResponse> =>
|
||||
api.post('/auth/mfa/setup').then((res) => res.data),
|
||||
|
||||
verifySetup: (dto: VerifyMfaSetupDto): Promise<{ success: boolean; message: string }> =>
|
||||
api.post('/auth/mfa/verify-setup', dto).then((res) => res.data),
|
||||
|
||||
disable: (dto: DisableMfaDto): Promise<{ success: boolean; message: string }> =>
|
||||
api.post('/auth/mfa/disable', dto).then((res) => res.data),
|
||||
|
||||
regenerateBackupCodes: (dto: RegenerateBackupCodesDto): Promise<BackupCodesResponse> =>
|
||||
api.post('/auth/mfa/backup-codes/regenerate', dto).then((res) => res.data),
|
||||
};
|
||||
|
||||
// Hooks
|
||||
|
||||
/**
|
||||
* Get MFA status for current user
|
||||
*/
|
||||
export function useMfaStatus() {
|
||||
return useQuery({
|
||||
queryKey: mfaKeys.status(),
|
||||
queryFn: mfaApi.getStatus,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MFA setup - returns QR code and secret
|
||||
*/
|
||||
export function useSetupMfa() {
|
||||
return useMutation({
|
||||
mutationFn: mfaApi.setup,
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to setup MFA';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify TOTP code and enable MFA
|
||||
*/
|
||||
export function useVerifyMfaSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: mfaApi.verifySetup,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || 'MFA enabled successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Invalid verification code';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable MFA
|
||||
*/
|
||||
export function useDisableMfa() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: mfaApi.disable,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || 'MFA disabled successfully');
|
||||
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to disable MFA';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate backup codes
|
||||
*/
|
||||
export function useRegenerateBackupCodes() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: mfaApi.regenerateBackupCodes,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || 'New backup codes generated');
|
||||
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to regenerate backup codes';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
74
src/hooks/useOAuth.ts
Normal file
74
src/hooks/useOAuth.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { oauthApi, OAuthConnection } from '@/services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// Query keys
|
||||
export const oauthKeys = {
|
||||
all: ['oauth'] as const,
|
||||
connections: () => [...oauthKeys.all, 'connections'] as const,
|
||||
};
|
||||
|
||||
// Get OAuth authorization URL mutation
|
||||
export function useOAuthUrl() {
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, mode }: { provider: string; mode: 'login' | 'register' | 'link' }) =>
|
||||
oauthApi.getAuthUrl(provider, mode),
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to get authorization URL';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Process OAuth callback mutation
|
||||
export function useOAuthCallback() {
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, code, state, idToken, userData }: {
|
||||
provider: string;
|
||||
code: string;
|
||||
state: string;
|
||||
idToken?: string; // For Apple OAuth
|
||||
userData?: string; // For Apple OAuth (first time only)
|
||||
}) =>
|
||||
oauthApi.handleCallback(provider, code, state, idToken, userData),
|
||||
// Don't show toast here - OAuthCallbackPage handles the messaging
|
||||
});
|
||||
}
|
||||
|
||||
// Get OAuth connections query
|
||||
export function useOAuthConnections() {
|
||||
return useQuery({
|
||||
queryKey: oauthKeys.connections(),
|
||||
queryFn: oauthApi.getConnections,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect OAuth provider mutation
|
||||
export function useDisconnectOAuth() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (provider: string) => oauthApi.disconnect(provider),
|
||||
onSuccess: (_, provider) => {
|
||||
queryClient.invalidateQueries({ queryKey: oauthKeys.connections() });
|
||||
toast.success(`Disconnected ${provider} successfully`);
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Failed to disconnect provider';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper hook to check if a provider is connected
|
||||
export function useIsProviderConnected(provider: string) {
|
||||
const { data: connections } = useOAuthConnections();
|
||||
return connections?.some((c: OAuthConnection) => c.provider === provider) ?? false;
|
||||
}
|
||||
296
src/hooks/useOnboarding.ts
Normal file
296
src/hooks/useOnboarding.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '@/services/api';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export type OnboardingStep = 'company' | 'invite' | 'plan' | 'complete';
|
||||
|
||||
export interface OnboardingState {
|
||||
currentStep: OnboardingStep;
|
||||
completedSteps: OnboardingStep[];
|
||||
companyData: CompanyData | null;
|
||||
invitedUsers: InvitedUser[];
|
||||
selectedPlanId: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyData {
|
||||
name: string;
|
||||
slug: string;
|
||||
domain?: string;
|
||||
logo_url?: string;
|
||||
industry?: string;
|
||||
size?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface InvitedUser {
|
||||
email: string;
|
||||
role: string;
|
||||
status: 'pending' | 'sent' | 'error';
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
features: string[];
|
||||
is_popular?: boolean;
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
const onboardingApi = {
|
||||
getStatus: async () => {
|
||||
const response = await api.get('/onboarding/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateCompany: async (data: CompanyData) => {
|
||||
const response = await api.patch('/tenants/current', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
inviteUsers: async (users: { email: string; role: string }[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
users.map((user) => api.post('/users/invite', user))
|
||||
);
|
||||
return results;
|
||||
},
|
||||
|
||||
getPlans: async (): Promise<Plan[]> => {
|
||||
const response = await api.get('/plans');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
selectPlan: async (planId: string) => {
|
||||
const response = await api.post('/billing/subscription', { plan_id: planId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
completeOnboarding: async () => {
|
||||
const response = await api.post('/onboarding/complete');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
|
||||
export const onboardingKeys = {
|
||||
all: ['onboarding'] as const,
|
||||
status: () => [...onboardingKeys.all, 'status'] as const,
|
||||
plans: () => [...onboardingKeys.all, 'plans'] as const,
|
||||
};
|
||||
|
||||
// ==================== Hooks ====================
|
||||
|
||||
const STEPS: OnboardingStep[] = ['company', 'invite', 'plan', 'complete'];
|
||||
|
||||
const STORAGE_KEY = 'onboarding_state';
|
||||
|
||||
function loadState(): Partial<OnboardingState> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: Partial<OnboardingState>) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnboarding() {
|
||||
const savedState = loadState();
|
||||
|
||||
const [state, setState] = useState<OnboardingState>({
|
||||
currentStep: savedState.currentStep || 'company',
|
||||
completedSteps: savedState.completedSteps || [],
|
||||
companyData: savedState.companyData || null,
|
||||
invitedUsers: savedState.invitedUsers || [],
|
||||
selectedPlanId: savedState.selectedPlanId || null,
|
||||
});
|
||||
|
||||
// Update state and persist
|
||||
const updateState = useCallback((updates: Partial<OnboardingState>) => {
|
||||
setState((prev) => {
|
||||
const newState = { ...prev, ...updates };
|
||||
saveState(newState);
|
||||
return newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Navigation
|
||||
const goToStep = useCallback((step: OnboardingStep) => {
|
||||
updateState({ currentStep: step });
|
||||
}, [updateState]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
const currentIndex = STEPS.indexOf(state.currentStep);
|
||||
if (currentIndex < STEPS.length - 1) {
|
||||
const nextStepName = STEPS[currentIndex + 1];
|
||||
updateState({
|
||||
currentStep: nextStepName,
|
||||
completedSteps: [...new Set([...state.completedSteps, state.currentStep])],
|
||||
});
|
||||
}
|
||||
}, [state.currentStep, state.completedSteps, updateState]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
const currentIndex = STEPS.indexOf(state.currentStep);
|
||||
if (currentIndex > 0) {
|
||||
updateState({ currentStep: STEPS[currentIndex - 1] });
|
||||
}
|
||||
}, [state.currentStep, updateState]);
|
||||
|
||||
const canGoNext = useCallback(() => {
|
||||
switch (state.currentStep) {
|
||||
case 'company':
|
||||
return !!state.companyData?.name && !!state.companyData?.slug;
|
||||
case 'invite':
|
||||
return true; // Optional step
|
||||
case 'plan':
|
||||
return !!state.selectedPlanId;
|
||||
case 'complete':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const canGoPrev = useCallback(() => {
|
||||
return STEPS.indexOf(state.currentStep) > 0 && state.currentStep !== 'complete';
|
||||
}, [state.currentStep]);
|
||||
|
||||
const getStepIndex = useCallback(() => {
|
||||
return STEPS.indexOf(state.currentStep);
|
||||
}, [state.currentStep]);
|
||||
|
||||
const getTotalSteps = useCallback(() => {
|
||||
return STEPS.length;
|
||||
}, []);
|
||||
|
||||
const isStepCompleted = useCallback((step: OnboardingStep) => {
|
||||
return state.completedSteps.includes(step);
|
||||
}, [state.completedSteps]);
|
||||
|
||||
// Reset
|
||||
const resetOnboarding = useCallback(() => {
|
||||
clearState();
|
||||
setState({
|
||||
currentStep: 'company',
|
||||
completedSteps: [],
|
||||
companyData: null,
|
||||
invitedUsers: [],
|
||||
selectedPlanId: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
updateState,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
canGoNext,
|
||||
canGoPrev,
|
||||
getStepIndex,
|
||||
getTotalSteps,
|
||||
isStepCompleted,
|
||||
resetOnboarding,
|
||||
steps: STEPS,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Data Hooks ====================
|
||||
|
||||
export function usePlans() {
|
||||
return useQuery({
|
||||
queryKey: onboardingKeys.plans(),
|
||||
queryFn: onboardingApi.getPlans,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCompany() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.updateCompany,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
||||
toast.success('Company information saved!');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to save company information');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInviteUsers() {
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.inviteUsers,
|
||||
onSuccess: (results) => {
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (successful > 0) {
|
||||
toast.success(`${successful} invitation${successful > 1 ? 's' : ''} sent!`);
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`${failed} invitation${failed > 1 ? 's' : ''} failed`);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to send invitations');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSelectPlan() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.selectPlan,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
toast.success('Plan selected successfully!');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to select plan');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteOnboarding() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.completeOnboarding,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant'] });
|
||||
toast.success('Welcome aboard! Your setup is complete.');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to complete onboarding');
|
||||
},
|
||||
});
|
||||
}
|
||||
335
src/hooks/usePushNotifications.ts
Normal file
335
src/hooks/usePushNotifications.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/services/api';
|
||||
|
||||
// Types
|
||||
export interface UserDevice {
|
||||
id: string;
|
||||
device_type: 'web' | 'mobile' | 'desktop';
|
||||
device_name: string | null;
|
||||
browser: string | null;
|
||||
os: string | null;
|
||||
is_active: boolean;
|
||||
last_used_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RegisterDeviceDto {
|
||||
deviceToken: string;
|
||||
deviceType?: 'web' | 'mobile' | 'desktop';
|
||||
deviceName?: string;
|
||||
browser?: string;
|
||||
browserVersion?: string;
|
||||
os?: string;
|
||||
osVersion?: string;
|
||||
}
|
||||
|
||||
export interface DeviceStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
byType: {
|
||||
web: number;
|
||||
mobile: number;
|
||||
desktop: number;
|
||||
};
|
||||
}
|
||||
|
||||
// API functions
|
||||
const devicesApi = {
|
||||
getVapidKey: async () => {
|
||||
const { data } = await api.get('/notifications/devices/vapid-key');
|
||||
return data as { vapidPublicKey: string | null; isEnabled: boolean };
|
||||
},
|
||||
|
||||
getDevices: async () => {
|
||||
const { data } = await api.get('/notifications/devices');
|
||||
return data as UserDevice[];
|
||||
},
|
||||
|
||||
registerDevice: async (dto: RegisterDeviceDto) => {
|
||||
const { data } = await api.post('/notifications/devices', dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
unregisterDevice: async (deviceId: string) => {
|
||||
await api.delete(`/notifications/devices/${deviceId}`);
|
||||
},
|
||||
|
||||
getStats: async () => {
|
||||
const { data } = await api.get('/notifications/devices/stats');
|
||||
return data as DeviceStats;
|
||||
},
|
||||
};
|
||||
|
||||
// Helper: Convert VAPID key to Uint8Array
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Helper: Get device name based on user agent
|
||||
function getDeviceName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
let browser = 'Unknown';
|
||||
if (ua.includes('Chrome')) browser = 'Chrome';
|
||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||
else if (ua.includes('Safari')) browser = 'Safari';
|
||||
else if (ua.includes('Edge')) browser = 'Edge';
|
||||
|
||||
let os = 'Unknown';
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac')) os = 'macOS';
|
||||
else if (ua.includes('Linux')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iOS')) os = 'iOS';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
// Helper: Get browser info
|
||||
function getBrowserInfo() {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
let browser = 'Unknown';
|
||||
let browserVersion = '';
|
||||
|
||||
if (ua.includes('Chrome')) {
|
||||
browser = 'Chrome';
|
||||
const match = ua.match(/Chrome\/(\d+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Firefox')) {
|
||||
browser = 'Firefox';
|
||||
const match = ua.match(/Firefox\/(\d+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Safari')) {
|
||||
browser = 'Safari';
|
||||
const match = ua.match(/Version\/(\d+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('Edge')) {
|
||||
browser = 'Edge';
|
||||
const match = ua.match(/Edg\/(\d+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
}
|
||||
|
||||
let os = 'Unknown';
|
||||
let osVersion = '';
|
||||
|
||||
if (ua.includes('Windows NT 10')) {
|
||||
os = 'Windows';
|
||||
osVersion = '10/11';
|
||||
} else if (ua.includes('Mac OS X')) {
|
||||
os = 'macOS';
|
||||
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
|
||||
osVersion = match ? match[1].replace('_', '.') : '';
|
||||
} else if (ua.includes('Linux')) {
|
||||
os = 'Linux';
|
||||
} else if (ua.includes('Android')) {
|
||||
os = 'Android';
|
||||
const match = ua.match(/Android (\d+)/);
|
||||
osVersion = match ? match[1] : '';
|
||||
}
|
||||
|
||||
return { browser, browserVersion, os, osVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing push notifications
|
||||
*/
|
||||
export function usePushNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch VAPID key
|
||||
const { data: vapidData } = useQuery({
|
||||
queryKey: ['push', 'vapid-key'],
|
||||
queryFn: devicesApi.getVapidKey,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
// Register device mutation
|
||||
const registerDevice = useMutation({
|
||||
mutationFn: devicesApi.registerDevice,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
setIsSubscribed(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Unregister device mutation
|
||||
const unregisterDevice = useMutation({
|
||||
mutationFn: devicesApi.unregisterDevice,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Check support and permission on mount
|
||||
useEffect(() => {
|
||||
const checkSupport = async () => {
|
||||
const supported =
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window;
|
||||
|
||||
setIsSupported(supported);
|
||||
|
||||
if (supported) {
|
||||
setPermission(Notification.permission);
|
||||
|
||||
// Check if already subscribed
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setIsSubscribed(!!subscription);
|
||||
} catch (err) {
|
||||
console.error('Error checking push subscription:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSupport();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Request notification permission and subscribe
|
||||
*/
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!isSupported || !vapidData?.isEnabled || !vapidData?.vapidPublicKey) {
|
||||
setError('Push notifications are not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Request permission
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
|
||||
if (result !== 'granted') {
|
||||
setError('Notification permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Register service worker if needed
|
||||
let registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) {
|
||||
registration = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
// Subscribe to push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidData.vapidPublicKey) as BufferSource,
|
||||
});
|
||||
|
||||
// Get device info
|
||||
const { browser, browserVersion, os, osVersion } = getBrowserInfo();
|
||||
|
||||
// Send to backend
|
||||
await registerDevice.mutateAsync({
|
||||
deviceToken: JSON.stringify(subscription),
|
||||
deviceType: 'web',
|
||||
deviceName: getDeviceName(),
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
osVersion,
|
||||
});
|
||||
|
||||
setIsSubscribed(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to enable push notifications';
|
||||
setError(message);
|
||||
console.error('Push subscription error:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isSupported, vapidData, registerDevice]);
|
||||
|
||||
/**
|
||||
* Unsubscribe from push notifications
|
||||
*/
|
||||
const unsubscribe = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to unsubscribe';
|
||||
setError(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isSupported,
|
||||
isEnabled: vapidData?.isEnabled ?? false,
|
||||
permission,
|
||||
isSubscribed,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
requestPermission,
|
||||
unsubscribe,
|
||||
|
||||
// Mutations
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing user devices
|
||||
*/
|
||||
export function useDevices() {
|
||||
return useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: devicesApi.getDevices,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for device statistics
|
||||
*/
|
||||
export function useDeviceStats() {
|
||||
return useQuery({
|
||||
queryKey: ['devices', 'stats'],
|
||||
queryFn: devicesApi.getStats,
|
||||
});
|
||||
}
|
||||
|
||||
export default usePushNotifications;
|
||||
105
src/hooks/useStorage.ts
Normal file
105
src/hooks/useStorage.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { storageApi, StorageFile, FileListResponse, StorageUsage, UpdateFileRequest } from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
export const storageKeys = {
|
||||
all: ['storage'] as const,
|
||||
files: () => [...storageKeys.all, 'files'] as const,
|
||||
filesList: (params?: { page?: number; limit?: number; folder?: string; mimeType?: string; search?: string }) =>
|
||||
[...storageKeys.files(), params] as const,
|
||||
file: (id: string) => [...storageKeys.files(), id] as const,
|
||||
usage: () => [...storageKeys.all, 'usage'] as const,
|
||||
};
|
||||
|
||||
// List files hook
|
||||
export function useFiles(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
folder?: string;
|
||||
mimeType?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
return useQuery<FileListResponse>({
|
||||
queryKey: storageKeys.filesList(params),
|
||||
queryFn: () => storageApi.listFiles(params),
|
||||
});
|
||||
}
|
||||
|
||||
// Get single file hook
|
||||
export function useFile(id: string) {
|
||||
return useQuery<StorageFile>({
|
||||
queryKey: storageKeys.file(id),
|
||||
queryFn: () => storageApi.getFile(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Storage usage hook
|
||||
export function useStorageUsage() {
|
||||
return useQuery<StorageUsage>({
|
||||
queryKey: storageKeys.usage(),
|
||||
queryFn: () => storageApi.getUsage(),
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file mutation
|
||||
export function useUploadFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
file: File;
|
||||
folder?: string;
|
||||
visibility?: 'private' | 'tenant' | 'public';
|
||||
metadata?: Record<string, any>;
|
||||
onProgress?: (progress: number) => void;
|
||||
}) =>
|
||||
storageApi.uploadFile(params.file, {
|
||||
folder: params.folder,
|
||||
visibility: params.visibility,
|
||||
metadata: params.metadata,
|
||||
onProgress: params.onProgress,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.usage() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update file mutation
|
||||
export function useUpdateFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateFileRequest }) =>
|
||||
storageApi.updateFile(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.file(id) });
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete file mutation
|
||||
export function useDeleteFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => storageApi.deleteFile(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
|
||||
queryClient.invalidateQueries({ queryKey: storageKeys.usage() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get download URL
|
||||
export function useDownloadUrl(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...storageKeys.file(id), 'download'],
|
||||
queryFn: () => storageApi.getDownloadUrl(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 50, // 50 minutes (URLs expire in 1 hour)
|
||||
});
|
||||
}
|
||||
284
src/hooks/useSuperadmin.ts
Normal file
284
src/hooks/useSuperadmin.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { superadminApi } from '@/services/api';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
|
||||
export const superadminKeys = {
|
||||
all: ['superadmin'] as const,
|
||||
dashboard: () => [...superadminKeys.all, 'dashboard'] as const,
|
||||
tenants: {
|
||||
all: () => [...superadminKeys.all, 'tenants'] as const,
|
||||
list: (params?: TenantListParams) => [...superadminKeys.tenants.all(), 'list', params] as const,
|
||||
detail: (id: string) => [...superadminKeys.tenants.all(), 'detail', id] as const,
|
||||
users: (id: string, page?: number, limit?: number) =>
|
||||
[...superadminKeys.tenants.all(), 'users', id, { page, limit }] as const,
|
||||
},
|
||||
metrics: {
|
||||
all: () => [...superadminKeys.all, 'metrics'] as const,
|
||||
summary: () => [...superadminKeys.metrics.all(), 'summary'] as const,
|
||||
tenantGrowth: (months: number) => [...superadminKeys.metrics.all(), 'tenant-growth', months] as const,
|
||||
userGrowth: (months: number) => [...superadminKeys.metrics.all(), 'user-growth', months] as const,
|
||||
planDistribution: () => [...superadminKeys.metrics.all(), 'plan-distribution'] as const,
|
||||
statusDistribution: () => [...superadminKeys.metrics.all(), 'status-distribution'] as const,
|
||||
topTenants: (limit: number) => [...superadminKeys.metrics.all(), 'top-tenants', limit] as const,
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface TenantListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
logo_url: string | null;
|
||||
status: 'active' | 'suspended' | 'trial' | 'canceled';
|
||||
plan_id: string | null;
|
||||
trial_ends_at: string | null;
|
||||
settings: Record<string, any> | null;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
userCount?: number;
|
||||
subscription?: {
|
||||
id: string;
|
||||
status: string;
|
||||
plan?: {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TenantListResponse {
|
||||
data: Tenant[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface SuperadminDashboardStats {
|
||||
totalTenants: number;
|
||||
activeTenants: number;
|
||||
trialTenants: number;
|
||||
suspendedTenants: number;
|
||||
totalUsers: number;
|
||||
newTenantsThisMonth: number;
|
||||
}
|
||||
|
||||
export interface CreateTenantData {
|
||||
name: string;
|
||||
slug: string;
|
||||
domain?: string;
|
||||
logo_url?: string;
|
||||
plan_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantData {
|
||||
name?: string;
|
||||
domain?: string;
|
||||
logo_url?: string;
|
||||
plan_id?: string;
|
||||
settings?: Record<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateTenantStatusData {
|
||||
status: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ==================== Dashboard Hooks ====================
|
||||
|
||||
export function useSuperadminDashboard() {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.dashboard(),
|
||||
queryFn: () => superadminApi.getDashboardStats() as Promise<SuperadminDashboardStats>,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Tenant Hooks ====================
|
||||
|
||||
export function useTenants(params?: TenantListParams) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.tenants.list(params),
|
||||
queryFn: () => superadminApi.listTenants(params) as Promise<TenantListResponse>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTenant(id: string) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.tenants.detail(id),
|
||||
queryFn: () => superadminApi.getTenant(id) as Promise<Tenant>,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTenantData) => superadminApi.createTenant(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
|
||||
toast.success('Tenant created successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to create tenant');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTenantData }) =>
|
||||
superadminApi.updateTenant(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.detail(variables.id) });
|
||||
toast.success('Tenant updated successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update tenant');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTenantStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTenantStatusData }) =>
|
||||
superadminApi.updateTenantStatus(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.detail(variables.id) });
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
|
||||
toast.success('Tenant status updated!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update tenant status');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => superadminApi.deleteTenant(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
|
||||
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
|
||||
toast.success('Tenant deleted successfully!');
|
||||
},
|
||||
onError: (error: AxiosError<ApiError>) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to delete tenant');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTenantUsers(tenantId: string, page = 1, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.tenants.users(tenantId, page, limit),
|
||||
queryFn: () => superadminApi.getTenantUsers(tenantId, { page, limit }),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Metrics Types ====================
|
||||
|
||||
export interface GrowthDataPoint {
|
||||
month: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DistributionDataPoint {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface TopTenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
userCount: number;
|
||||
status: string;
|
||||
planName: string;
|
||||
}
|
||||
|
||||
export interface MetricsSummary {
|
||||
tenantGrowth: GrowthDataPoint[];
|
||||
userGrowth: GrowthDataPoint[];
|
||||
planDistribution: DistributionDataPoint[];
|
||||
statusDistribution: DistributionDataPoint[];
|
||||
topTenants: TopTenant[];
|
||||
}
|
||||
|
||||
// ==================== Metrics Hooks ====================
|
||||
|
||||
export function useMetricsSummary() {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.summary(),
|
||||
queryFn: () => superadminApi.getMetricsSummary() as Promise<MetricsSummary>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTenantGrowth(months = 12) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.tenantGrowth(months),
|
||||
queryFn: () => superadminApi.getTenantGrowth(months) as Promise<GrowthDataPoint[]>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserGrowth(months = 12) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.userGrowth(months),
|
||||
queryFn: () => superadminApi.getUserGrowth(months) as Promise<GrowthDataPoint[]>,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanDistribution() {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.planDistribution(),
|
||||
queryFn: () => superadminApi.getPlanDistribution() as Promise<DistributionDataPoint[]>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStatusDistribution() {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.statusDistribution(),
|
||||
queryFn: () => superadminApi.getStatusDistribution() as Promise<DistributionDataPoint[]>,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopTenants(limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: superadminKeys.metrics.topTenants(limit),
|
||||
queryFn: () => superadminApi.getTopTenants(limit) as Promise<TopTenant[]>,
|
||||
});
|
||||
}
|
||||
148
src/hooks/useWebhooks.ts
Normal file
148
src/hooks/useWebhooks.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
webhooksApi,
|
||||
Webhook,
|
||||
WebhookEvent,
|
||||
CreateWebhookRequest,
|
||||
UpdateWebhookRequest,
|
||||
DeliveryStatus,
|
||||
PaginatedDeliveries,
|
||||
} from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
const WEBHOOKS_KEY = ['webhooks'];
|
||||
const WEBHOOK_KEY = (id: string) => ['webhooks', id];
|
||||
const WEBHOOK_EVENTS_KEY = ['webhooks', 'events'];
|
||||
const WEBHOOK_DELIVERIES_KEY = (id: string) => ['webhooks', id, 'deliveries'];
|
||||
|
||||
// List all webhooks
|
||||
export function useWebhooks() {
|
||||
return useQuery<Webhook[]>({
|
||||
queryKey: WEBHOOKS_KEY,
|
||||
queryFn: webhooksApi.list,
|
||||
});
|
||||
}
|
||||
|
||||
// Get single webhook
|
||||
export function useWebhook(id: string) {
|
||||
return useQuery<Webhook>({
|
||||
queryKey: WEBHOOK_KEY(id),
|
||||
queryFn: () => webhooksApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Get available events
|
||||
export function useWebhookEvents() {
|
||||
return useQuery<WebhookEvent[]>({
|
||||
queryKey: WEBHOOK_EVENTS_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await webhooksApi.getEvents();
|
||||
return response.events;
|
||||
},
|
||||
staleTime: 1000 * 60 * 60, // 1 hour - events don't change often
|
||||
});
|
||||
}
|
||||
|
||||
// Get webhook deliveries
|
||||
export function useWebhookDeliveries(
|
||||
webhookId: string,
|
||||
params?: { status?: DeliveryStatus; eventType?: string; page?: number; limit?: number }
|
||||
) {
|
||||
return useQuery<PaginatedDeliveries>({
|
||||
queryKey: [...WEBHOOK_DELIVERIES_KEY(webhookId), params],
|
||||
queryFn: () => webhooksApi.getDeliveries(webhookId, params),
|
||||
enabled: !!webhookId,
|
||||
});
|
||||
}
|
||||
|
||||
// Create webhook
|
||||
export function useCreateWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateWebhookRequest) => webhooksApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update webhook
|
||||
export function useUpdateWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateWebhookRequest }) =>
|
||||
webhooksApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete webhook
|
||||
export function useDeleteWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => webhooksApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle webhook active status
|
||||
export function useToggleWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
webhooksApi.update(id, { isActive }),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate secret
|
||||
export function useRegenerateWebhookSecret() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => webhooksApi.regenerateSecret(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Test webhook
|
||||
export function useTestWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload?: { eventType?: string; payload?: Record<string, any> } }) =>
|
||||
webhooksApi.test(id, payload),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_DELIVERIES_KEY(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Retry delivery
|
||||
export function useRetryDelivery() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ webhookId, deliveryId }: { webhookId: string; deliveryId: string }) =>
|
||||
webhooksApi.retryDelivery(webhookId, deliveryId),
|
||||
onSuccess: (_, { webhookId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_DELIVERIES_KEY(webhookId) });
|
||||
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(webhookId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
126
src/hooks/useWhatsApp.ts
Normal file
126
src/hooks/useWhatsApp.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { whatsappApi, CreateWhatsAppConfigDto, UpdateWhatsAppConfigDto } from '../services/whatsapp.api';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
config: ['whatsapp', 'config'],
|
||||
messages: ['whatsapp', 'messages'],
|
||||
};
|
||||
|
||||
export function useWhatsAppConfig() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.config,
|
||||
queryFn: whatsappApi.getConfig,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWhatsAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateWhatsAppConfigDto) => whatsappApi.createConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
|
||||
toast.success('La configuracion de WhatsApp ha sido guardada exitosamente.');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al configurar WhatsApp');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWhatsAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateWhatsAppConfigDto) => whatsappApi.updateConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
|
||||
toast.success('Los cambios han sido guardados.');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al actualizar configuracion');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWhatsAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => whatsappApi.deleteConfig(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
|
||||
toast.success('La integracion de WhatsApp ha sido eliminada.');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al eliminar configuracion');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestWhatsAppConnection() {
|
||||
return useMutation({
|
||||
mutationFn: (phoneNumber: string) => whatsappApi.testConnection(phoneNumber),
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success('El mensaje de prueba se envio correctamente.');
|
||||
} else {
|
||||
toast.error(result.error || 'No se pudo enviar el mensaje de prueba.');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al probar conexion');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWhatsAppMessages(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
phoneNumber?: string;
|
||||
direction?: 'inbound' | 'outbound';
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.messages, params],
|
||||
queryFn: () => whatsappApi.getMessages(params),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendWhatsAppTextMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: whatsappApi.sendTextMessage,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.messages });
|
||||
if (result.success) {
|
||||
toast.success('El mensaje ha sido enviado exitosamente.');
|
||||
} else {
|
||||
toast.error(result.error || 'Error al enviar mensaje');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al enviar mensaje');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendWhatsAppTemplateMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: whatsappApi.sendTemplateMessage,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.messages });
|
||||
if (result.success) {
|
||||
toast.success('El template ha sido enviado exitosamente.');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Error al enviar template');
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/index.css
Normal file
57
src/index.css
Normal file
@ -0,0 +1,57 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 text-secondary-900 dark:bg-secondary-900 dark:text-secondary-50;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-200 text-secondary-900 hover:bg-secondary-300 focus:ring-secondary-500 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn bg-transparent hover:bg-secondary-100 dark:hover:bg-secondary-800;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 text-sm border border-secondary-300 rounded-lg bg-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-secondary-800 dark:border-secondary-600 dark:text-white;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-secondary-200 dark:bg-secondary-800 dark:border-secondary-700;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
}
|
||||
64
src/layouts/AuthLayout.tsx
Normal file
64
src/layouts/AuthLayout.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-primary-600 text-white p-12 flex-col justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Template SaaS</h1>
|
||||
<p className="mt-2 text-primary-100">Multi-tenant Platform</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Multi-tenant Architecture</h3>
|
||||
<p className="text-sm text-primary-200">Complete data isolation per tenant</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Enterprise Security</h3>
|
||||
<p className="text-sm text-primary-200">RBAC, RLS, and audit logs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Stripe Integration</h3>
|
||||
<p className="text-sm text-primary-200">Subscriptions and billing portal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-primary-200">
|
||||
© {new Date().getFullYear()} Template SaaS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right side - Auth forms */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/layouts/DashboardLayout.tsx
Normal file
201
src/layouts/DashboardLayout.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import { Outlet, NavLink } from 'react-router-dom';
|
||||
import { useAuthStore, useUIStore } from '@/stores';
|
||||
import { useLogout } from '@/hooks';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Bot,
|
||||
HardDrive,
|
||||
Webhook,
|
||||
ClipboardList,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { NotificationBell } from '@/components/notifications';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'AI Assistant', href: '/dashboard/ai', icon: Bot },
|
||||
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
|
||||
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
|
||||
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },
|
||||
{ name: 'Audit Logs', href: '/dashboard/audit', icon: ClipboardList },
|
||||
{ name: 'Users', href: '/dashboard/users', icon: Users },
|
||||
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
||||
{ name: 'Settings', href: '/dashboard/settings', icon: Settings },
|
||||
{ name: 'WhatsApp', href: '/dashboard/whatsapp', icon: MessageSquare },
|
||||
];
|
||||
|
||||
const superadminNavigation = [
|
||||
{ name: 'Tenants', href: '/superadmin/tenants', icon: Building2 },
|
||||
{ name: 'Metrics', href: '/superadmin/metrics', icon: BarChart3 },
|
||||
];
|
||||
|
||||
export function DashboardLayout() {
|
||||
const { user } = useAuthStore();
|
||||
const { sidebarOpen, toggleSidebar } = useUIStore();
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Check if user is superadmin
|
||||
const isSuperadmin = user?.role === 'superadmin';
|
||||
|
||||
const handleLogout = () => {
|
||||
setUserMenuOpen(false);
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50 dark:bg-secondary-900">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-secondary-800 border-r border-secondary-200 dark:border-secondary-700 transform transition-transform duration-200 ease-in-out lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
{/* Regular navigation */}
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
end={item.href === '/dashboard'}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Superadmin navigation */}
|
||||
{isSuperadmin && (
|
||||
<>
|
||||
<div className="pt-4 mt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-secondary-500 uppercase tracking-wider">
|
||||
<Shield className="w-4 h-4" />
|
||||
Superadmin
|
||||
</div>
|
||||
</div>
|
||||
{superadminNavigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={clsx('lg:pl-64 flex flex-col min-h-screen')}>
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-30 h-16 bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700 px-4 lg:px-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
{/* Notifications */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">{user?.role}</p>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/layouts/index.ts
Normal file
2
src/layouts/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './AuthLayout';
|
||||
export * from './DashboardLayout';
|
||||
34
src/main.tsx
Normal file
34
src/main.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#1e293b',
|
||||
color: '#f1f5f9',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
262
src/pages/admin/AnalyticsDashboardPage.tsx
Normal file
262
src/pages/admin/AnalyticsDashboardPage.tsx
Normal file
@ -0,0 +1,262 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
DollarSign,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { MetricCard } from '@/components/analytics/MetricCard';
|
||||
import { TrendChart } from '@/components/analytics/TrendChart';
|
||||
import {
|
||||
useAnalyticsSummary,
|
||||
useAnalyticsTrends,
|
||||
useUsageMetrics,
|
||||
getAvailablePeriods,
|
||||
getPeriodLabel,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
type AnalyticsPeriod,
|
||||
} from '@/hooks/useAnalytics';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function AnalyticsDashboardPage() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<AnalyticsPeriod>('30d');
|
||||
|
||||
// Fetch data
|
||||
const { data: summary, isLoading: summaryLoading } = useAnalyticsSummary();
|
||||
const { data: trends, isLoading: trendsLoading } = useAnalyticsTrends(selectedPeriod);
|
||||
const { data: usage, isLoading: usageLoading } = useUsageMetrics(selectedPeriod);
|
||||
|
||||
const periods = getAvailablePeriods();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with period selector */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Analytics Dashboard
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Monitor your key business metrics and trends
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Period:</span>
|
||||
<div className="flex bg-secondary-100 dark:bg-secondary-800 rounded-lg p-1">
|
||||
{periods.map((period) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => setSelectedPeriod(period.value)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-colors',
|
||||
selectedPeriod === period.value
|
||||
? 'bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white shadow-sm'
|
||||
: 'text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
{period.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Metric Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Active Users"
|
||||
value={summary?.users.active ?? 0}
|
||||
change={summary?.users.change}
|
||||
trend={summary?.users.change !== undefined ? (summary.users.change >= 0 ? 'up' : 'down') : undefined}
|
||||
icon={Users}
|
||||
isLoading={summaryLoading}
|
||||
subtitle={`${summary?.users.total ?? 0} total users`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Monthly Revenue (MRR)"
|
||||
value={summary?.revenue.mrr ?? 0}
|
||||
change={summary?.revenue.change}
|
||||
trend={summary?.revenue.change !== undefined ? (summary.revenue.change >= 0 ? 'up' : 'down') : undefined}
|
||||
icon={DollarSign}
|
||||
isLoading={summaryLoading}
|
||||
format="currency"
|
||||
subtitle={`${formatCurrency(summary?.revenue.arr ?? 0)} ARR`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Total Actions"
|
||||
value={summary?.usage.actions ?? 0}
|
||||
change={summary?.usage.change}
|
||||
trend={summary?.usage.change !== undefined ? (summary.usage.change >= 0 ? 'up' : 'down') : undefined}
|
||||
icon={Activity}
|
||||
isLoading={summaryLoading}
|
||||
subtitle={`${formatNumber(summary?.usage.avgPerUser ?? 0)} avg/user`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Engagement Rate"
|
||||
value={summary?.engagement.rate ?? 0}
|
||||
change={summary?.engagement.change}
|
||||
trend={summary?.engagement.change !== undefined ? (summary.engagement.change >= 0 ? 'up' : 'down') : undefined}
|
||||
icon={TrendingUp}
|
||||
isLoading={summaryLoading}
|
||||
format="percentage"
|
||||
subtitle={`${formatNumber(summary?.engagement.sessions ?? 0)} sessions`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<TrendChart
|
||||
title="User Growth"
|
||||
data={trends?.users ?? []}
|
||||
color="#3b82f6"
|
||||
isLoading={trendsLoading}
|
||||
height={320}
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
title="Revenue Trend"
|
||||
data={trends?.revenue ?? []}
|
||||
color="#10b981"
|
||||
isLoading={trendsLoading}
|
||||
height={320}
|
||||
valueFormatter={(val) => formatCurrency(val, true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage Metrics Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Actions Trend Chart */}
|
||||
<div className="lg:col-span-2">
|
||||
<TrendChart
|
||||
title="Daily Actions"
|
||||
data={trends?.actions ?? []}
|
||||
color="#8b5cf6"
|
||||
isLoading={trendsLoading}
|
||||
height={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Features Table */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart3 className="w-5 h-5 text-secondary-500" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Top Features
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{usageLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="h-4 w-24 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
<div className="h-4 w-12 bg-secondary-200 dark:bg-secondary-700 rounded" />
|
||||
</div>
|
||||
<div className="h-2 bg-secondary-200 dark:bg-secondary-700 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : usage?.topFeatures && usage.topFeatures.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{usage.topFeatures.slice(0, 5).map((feature, index) => (
|
||||
<div key={feature.feature}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300 truncate">
|
||||
{feature.feature}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{formatNumber(feature.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
index === 0
|
||||
? 'bg-primary-500'
|
||||
: index === 1
|
||||
? 'bg-primary-400'
|
||||
: index === 2
|
||||
? 'bg-primary-300'
|
||||
: 'bg-primary-200'
|
||||
)}
|
||||
style={{ width: `${feature.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No feature data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage by Hour */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-5 h-5 text-secondary-500" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Activity by Hour
|
||||
</h3>
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
({getPeriodLabel(selectedPeriod)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{usageLoading ? (
|
||||
<div className="animate-pulse flex items-end justify-between h-24 gap-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-secondary-200 dark:bg-secondary-700 rounded-t"
|
||||
style={{ height: `${Math.random() * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : usage?.byHour && usage.byHour.length > 0 ? (
|
||||
<div className="flex items-end justify-between h-24 gap-1">
|
||||
{usage.byHour.map((hourData) => {
|
||||
const maxActions = Math.max(...usage.byHour.map((h) => h.actions));
|
||||
const height = maxActions > 0 ? (hourData.actions / maxActions) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={hourData.hour}
|
||||
className="flex-1 flex flex-col items-center gap-1 group"
|
||||
title={`${hourData.hour}:00 - ${formatNumber(hourData.actions)} actions`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t transition-all group-hover:bg-primary-600 dark:group-hover:bg-primary-300"
|
||||
style={{ height: `${Math.max(height, 2)}%` }}
|
||||
/>
|
||||
{hourData.hour % 4 === 0 && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
{hourData.hour}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No hourly data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
src/pages/admin/WhatsAppSettings.tsx
Normal file
407
src/pages/admin/WhatsAppSettings.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useWhatsAppConfig,
|
||||
useCreateWhatsAppConfig,
|
||||
useUpdateWhatsAppConfig,
|
||||
useDeleteWhatsAppConfig,
|
||||
useWhatsAppMessages,
|
||||
} from '../../hooks/useWhatsApp';
|
||||
import { WhatsAppTestMessage } from '../../components/whatsapp/WhatsAppTestMessage';
|
||||
|
||||
export function WhatsAppSettings() {
|
||||
const { data: config, isLoading } = useWhatsAppConfig();
|
||||
const createConfig = useCreateWhatsAppConfig();
|
||||
const updateConfig = useUpdateWhatsAppConfig();
|
||||
const deleteConfig = useDeleteWhatsAppConfig();
|
||||
const { data: messagesData } = useWhatsAppMessages({ limit: 5 });
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
phoneNumberId: '',
|
||||
businessAccountId: '',
|
||||
accessToken: '',
|
||||
webhookVerifyToken: '',
|
||||
dailyMessageLimit: 1000,
|
||||
});
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'number' ? parseInt(value, 10) : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (config) {
|
||||
updateConfig.mutate({
|
||||
phoneNumberId: formData.phoneNumberId || undefined,
|
||||
businessAccountId: formData.businessAccountId || undefined,
|
||||
accessToken: formData.accessToken || undefined,
|
||||
webhookVerifyToken: formData.webhookVerifyToken || undefined,
|
||||
dailyMessageLimit: formData.dailyMessageLimit,
|
||||
});
|
||||
} else {
|
||||
createConfig.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteConfig.mutate();
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handleToggleActive = () => {
|
||||
if (config) {
|
||||
updateConfig.mutate({ isActive: !config.isActive });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg className="h-8 w-8 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" />
|
||||
</svg>
|
||||
Configuracion de WhatsApp
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configura la integracion con WhatsApp Business API para enviar notificaciones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
{config && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-gray-900">Estado de la integracion</h2>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
config.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{config.isActive ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
config.isVerified
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{config.isVerified ? 'Verificado' : 'Sin verificar'}
|
||||
</span>
|
||||
{config.qualityRating && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
config.qualityRating === 'GREEN'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: config.qualityRating === 'YELLOW'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
Calidad: {config.qualityRating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleActive}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${
|
||||
config.isActive
|
||||
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{config.isActive ? 'Desactivar' : 'Activar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.displayPhoneNumber && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Numero:</span>
|
||||
<span className="ml-2 font-medium">{config.displayPhoneNumber}</span>
|
||||
</div>
|
||||
{config.verifiedName && (
|
||||
<div>
|
||||
<span className="text-gray-500">Nombre verificado:</span>
|
||||
<span className="ml-2 font-medium">{config.verifiedName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Mensajes hoy:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{config.messagesSentToday} / {config.dailyMessageLimit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Form */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{config ? 'Actualizar configuracion' : 'Configurar WhatsApp Business API'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phoneNumberId"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Phone Number ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="phoneNumberId"
|
||||
name="phoneNumberId"
|
||||
value={formData.phoneNumberId}
|
||||
onChange={handleInputChange}
|
||||
placeholder={config?.phoneNumberId || 'ID del numero de telefono'}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
required={!config}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="businessAccountId"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Business Account ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="businessAccountId"
|
||||
name="businessAccountId"
|
||||
value={formData.businessAccountId}
|
||||
onChange={handleInputChange}
|
||||
placeholder={config?.businessAccountId || 'ID de la cuenta de negocio'}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
required={!config}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="accessToken"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Access Token *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
value={formData.accessToken}
|
||||
onChange={handleInputChange}
|
||||
placeholder={config ? '********' : 'Token de acceso de Meta'}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
required={!config}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Obten el token en{' '}
|
||||
<a
|
||||
href="https://developers.facebook.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:underline"
|
||||
>
|
||||
Meta for Developers
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="webhookVerifyToken"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Webhook Verify Token
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="webhookVerifyToken"
|
||||
name="webhookVerifyToken"
|
||||
value={formData.webhookVerifyToken}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Token para verificacion de webhook"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="dailyMessageLimit"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Limite diario de mensajes
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="dailyMessageLimit"
|
||||
name="dailyMessageLimit"
|
||||
value={formData.dailyMessageLimit}
|
||||
onChange={handleInputChange}
|
||||
min={1}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createConfig.isPending || updateConfig.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
||||
>
|
||||
{createConfig.isPending || updateConfig.isPending
|
||||
? 'Guardando...'
|
||||
: config
|
||||
? 'Actualizar configuracion'
|
||||
: 'Guardar configuracion'}
|
||||
</button>
|
||||
|
||||
{config && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
|
||||
>
|
||||
Eliminar integracion
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
{config && config.isActive && (
|
||||
<div className="mb-6">
|
||||
<WhatsAppTestMessage disabled={!config.isVerified} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Messages */}
|
||||
{config && messagesData && messagesData.data.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Mensajes recientes
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{messagesData.data.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg ${
|
||||
message.direction === 'outbound' ? 'bg-green-50' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center ${
|
||||
message.direction === 'outbound'
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{message.direction === 'outbound' ? (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{message.phoneNumber}
|
||||
{message.contactName && (
|
||||
<span className="text-gray-500 ml-1">({message.contactName})</span>
|
||||
)}
|
||||
</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
message.status === 'delivered' || message.status === 'read'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: message.status === 'failed'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{message.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 truncate">
|
||||
{message.content || message.templateName || '[Media]'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href="/admin/whatsapp/messages"
|
||||
className="text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
Ver todos los mensajes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Eliminar integracion de WhatsApp
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Esta seguro de que desea eliminar la integracion de WhatsApp? Esta
|
||||
accion no se puede deshacer y se perdera toda la configuracion.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfig.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteConfig.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/pages/auth/ForgotPasswordPage.tsx
Normal file
141
src/pages/auth/ForgotPasswordPage.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRequestPasswordReset } from '@/hooks';
|
||||
import { Loader2, ArrowLeft, CheckCircle, Mail } from 'lucide-react';
|
||||
|
||||
interface ForgotPasswordFormData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const resetMutation = useRequestPasswordReset();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
} = useForm<ForgotPasswordFormData>();
|
||||
|
||||
const onSubmit = (data: ForgotPasswordFormData) => {
|
||||
resetMutation.mutate(data.email, {
|
||||
onSuccess: () => setEmailSent(true),
|
||||
onError: () => setEmailSent(true), // Still show success for security
|
||||
});
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||
Check your email
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
We've sent password reset instructions to:
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white mb-6">
|
||||
{getValues('email')}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 mb-6 text-left">
|
||||
<div className="flex gap-3">
|
||||
<Mail className="w-5 h-5 text-secondary-500 mt-0.5" />
|
||||
<div className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="mb-2">
|
||||
If you don't see the email, check your spam folder.
|
||||
</p>
|
||||
<p>
|
||||
The link will expire in 1 hour for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="inline-flex items-center justify-center gap-2 text-primary-600 hover:text-primary-500 font-medium w-full"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
|
||||
Enter your email and we'll send you reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
disabled={resetMutation.isPending}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetMutation.isPending}
|
||||
className="btn-primary w-full py-2.5"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send reset instructions'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center">
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/pages/auth/LoginPage.tsx
Normal file
155
src/pages/auth/LoginPage.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useLogin } from '@/hooks';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { OAuthButtons, OAuthSeparator } from '@/components/auth';
|
||||
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const loginMutation = useLogin();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>();
|
||||
|
||||
const onSubmit = (data: LoginFormData) => {
|
||||
loginMutation.mutate({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
disabled={loginMutation.isPending}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className="input pr-10"
|
||||
placeholder="Enter your password"
|
||||
disabled={loginMutation.isPending}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
{...register('remember')}
|
||||
/>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Remember me
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Link
|
||||
to="/auth/forgot-password"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="btn-primary w-full py-2.5"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<OAuthSeparator />
|
||||
<OAuthButtons mode="login" disabled={loginMutation.isPending} />
|
||||
|
||||
<p className="mt-6 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/auth/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/pages/auth/OAuthCallbackPage.tsx
Normal file
144
src/pages/auth/OAuthCallbackPage.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useOAuthCallback } from '@/hooks/useOAuth';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type CallbackStatus = 'processing' | 'success' | 'error';
|
||||
|
||||
export function OAuthCallbackPage() {
|
||||
const { provider } = useParams<{ provider: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login } = useAuthStore();
|
||||
|
||||
const [status, setStatus] = useState<CallbackStatus>('processing');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const oauthCallback = useOAuthCallback();
|
||||
|
||||
useEffect(() => {
|
||||
const processCallback = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
// Apple OAuth specific params
|
||||
const idToken = searchParams.get('id_token') || undefined;
|
||||
const userData = searchParams.get('user') || undefined;
|
||||
|
||||
// Handle OAuth error from provider
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
setErrorMessage(errorDescription || error || 'Authentication failed');
|
||||
toast.error(errorDescription || 'Authentication failed');
|
||||
setTimeout(() => navigate('/auth/login'), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required params
|
||||
if (!code || !state || !provider) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Invalid callback parameters');
|
||||
toast.error('Invalid callback parameters');
|
||||
setTimeout(() => navigate('/auth/login'), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await oauthCallback.mutateAsync({
|
||||
provider,
|
||||
code,
|
||||
state,
|
||||
idToken, // Apple OAuth
|
||||
userData, // Apple OAuth (first time only)
|
||||
});
|
||||
|
||||
// Success - save tokens and redirect
|
||||
if (response.accessToken && response.refreshToken && response.user) {
|
||||
login(
|
||||
{
|
||||
id: response.user.id,
|
||||
email: response.user.email,
|
||||
first_name: response.user.first_name || '',
|
||||
last_name: response.user.last_name || '',
|
||||
role: 'user',
|
||||
tenant_id: response.user.tenant_id,
|
||||
},
|
||||
response.accessToken,
|
||||
response.refreshToken
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
setStatus('success');
|
||||
toast.success('Successfully authenticated!');
|
||||
|
||||
// Redirect to dashboard after brief success message
|
||||
setTimeout(() => navigate('/dashboard'), 1500);
|
||||
} else {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setStatus('error');
|
||||
const message = error.response?.data?.message || error.message || 'Authentication failed';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
|
||||
// Redirect to login after error
|
||||
setTimeout(() => navigate('/auth/login'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
processCallback();
|
||||
}, [provider, searchParams, navigate, login, queryClient, oauthCallback]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 dark:bg-secondary-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-lg p-8 text-center">
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<Loader2 className="w-16 h-16 mx-auto text-primary-600 animate-spin mb-4" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
Completing sign in...
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
Please wait while we verify your credentials with {provider}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-16 h-16 mx-auto text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
Successfully authenticated!
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
Redirecting you to the dashboard...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="w-16 h-16 mx-auto text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
Authentication failed
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
{errorMessage}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/pages/auth/RegisterPage.tsx
Normal file
257
src/pages/auth/RegisterPage.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRegister } from '@/hooks';
|
||||
import { Eye, EyeOff, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { OAuthButtons, OAuthSeparator } from '@/components/auth';
|
||||
|
||||
interface RegisterFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
// Password requirements
|
||||
const passwordRequirements = [
|
||||
{ id: 'length', label: 'At least 8 characters', test: (p: string) => p.length >= 8 },
|
||||
{ id: 'upper', label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
|
||||
{ id: 'lower', label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
|
||||
{ id: 'number', label: 'One number', test: (p: string) => /\d/.test(p) },
|
||||
];
|
||||
|
||||
export function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const registerMutation = useRegister();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>();
|
||||
|
||||
const password = watch('password', '');
|
||||
|
||||
const onSubmit = (data: RegisterFormData) => {
|
||||
registerMutation.mutate({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
});
|
||||
};
|
||||
|
||||
const allPasswordRequirementsMet = passwordRequirements.every((req) =>
|
||||
req.test(password)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
|
||||
Start your 14-day free trial
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="label">
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="John"
|
||||
disabled={registerMutation.isPending}
|
||||
{...register('first_name', { required: 'First name is required' })}
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.first_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="last_name" className="label">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Doe"
|
||||
disabled={registerMutation.isPending}
|
||||
{...register('last_name', { required: 'Last name is required' })}
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.last_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
disabled={registerMutation.isPending}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="input pr-10"
|
||||
placeholder="Create a password"
|
||||
disabled={registerMutation.isPending}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
validate: () =>
|
||||
allPasswordRequirementsMet || 'Password does not meet requirements',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password requirements */}
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(password);
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 text-xs',
|
||||
met ? 'text-green-600' : 'text-secondary-500'
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{req.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.password && !password && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="Confirm your password"
|
||||
disabled={registerMutation.isPending}
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: (value) =>
|
||||
value === password || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="terms"
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm text-secondary-600 dark:text-secondary-400"
|
||||
>
|
||||
I agree to the{' '}
|
||||
<a href="#" className="text-primary-600 hover:underline">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="#" className="text-primary-600 hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="btn-primary w-full py-2.5"
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<OAuthSeparator />
|
||||
<OAuthButtons mode="register" disabled={registerMutation.isPending} />
|
||||
|
||||
<p className="mt-6 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/pages/auth/index.ts
Normal file
4
src/pages/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './LoginPage';
|
||||
export * from './RegisterPage';
|
||||
export * from './ForgotPasswordPage';
|
||||
export * from './OAuthCallbackPage';
|
||||
107
src/pages/dashboard/AIPage.tsx
Normal file
107
src/pages/dashboard/AIPage.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { Bot, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { AIChat } from '@/components/ai';
|
||||
import { useCurrentAIUsage, useAIConfig } from '@/hooks/useAI';
|
||||
|
||||
export function AIPage() {
|
||||
const { data: usage } = useCurrentAIUsage();
|
||||
const { data: config } = useAIConfig();
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
AI Assistant
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Chat with your AI assistant powered by {config?.provider || 'OpenRouter'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500">Requests This Month</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{usage?.request_count?.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500">Tokens Used</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{formatNumber(usage?.total_tokens || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500">Avg Response Time</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{Math.round(usage?.avg_latency_ms || 0)}ms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Interface */}
|
||||
<AIChat
|
||||
className="h-[600px]"
|
||||
showModelSelector={config?.allow_custom_prompts}
|
||||
placeholder="Ask me anything..."
|
||||
/>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="card p-4">
|
||||
<h3 className="font-medium text-secondary-900 dark:text-white mb-3">
|
||||
Tips for better results
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-500">•</span>
|
||||
Be specific and provide context for your questions
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-500">•</span>
|
||||
Break complex tasks into smaller, focused requests
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-500">•</span>
|
||||
Use follow-up questions to refine responses
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-500">•</span>
|
||||
Clear the chat to start fresh conversations
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/pages/dashboard/AuditLogsPage.tsx
Normal file
276
src/pages/dashboard/AuditLogsPage.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { QueryAuditLogsParams } from '@/services/api';
|
||||
import {
|
||||
useAuditLogs,
|
||||
useAuditStats,
|
||||
useActivityLogs,
|
||||
useActivitySummary,
|
||||
} from '@/hooks/useAudit';
|
||||
import {
|
||||
AuditLogRow,
|
||||
AuditStatsCard,
|
||||
AuditFilters,
|
||||
ActivityTimeline,
|
||||
} from '@/components/audit';
|
||||
import { ExportButton } from '@/components/common';
|
||||
import {
|
||||
ClipboardList,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'logs' | 'activity';
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [filters, setFilters] = useState<QueryAuditLogsParams>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// Queries
|
||||
const { data: auditLogsData, isLoading: logsLoading } = useAuditLogs(filters);
|
||||
const { data: auditStats, isLoading: statsLoading } = useAuditStats(7);
|
||||
const { data: activitiesData, isLoading: activitiesLoading } = useActivityLogs({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
const { data: activitySummary, isLoading: summaryLoading } = useActivitySummary(30);
|
||||
|
||||
// Extract unique entity types from stats for filters
|
||||
const entityTypes = useMemo(() => {
|
||||
if (!auditStats?.by_entity_type) return [];
|
||||
return Object.keys(auditStats.by_entity_type);
|
||||
}, [auditStats]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters((prev) => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Audit Logs
|
||||
</h1>
|
||||
<p className="text-secondary-500 mt-1">
|
||||
Track and monitor all system activities and changes
|
||||
</p>
|
||||
</div>
|
||||
<ExportButton reportType="audit" />
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'logs'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300'
|
||||
)}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
Audit Logs
|
||||
{auditLogsData?.total !== undefined && (
|
||||
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 rounded-full text-xs">
|
||||
{auditLogsData.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('activity')}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'activity'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300'
|
||||
)}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Activity
|
||||
{activitiesData?.total !== undefined && (
|
||||
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 rounded-full text-xs">
|
||||
{activitiesData.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'logs' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<AuditFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
entityTypes={entityTypes}
|
||||
/>
|
||||
|
||||
{/* Logs list */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
|
||||
</div>
|
||||
) : !auditLogsData?.items?.length ? (
|
||||
<div className="text-center py-12">
|
||||
<ClipboardList className="w-12 h-12 mx-auto text-secondary-400 mb-3" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-1">
|
||||
No audit logs found
|
||||
</h3>
|
||||
<p className="text-secondary-500">
|
||||
{Object.keys(filters).filter((k) => k !== 'page' && k !== 'limit').length > 0
|
||||
? 'Try adjusting your filters'
|
||||
: 'Audit logs will appear here as actions are performed'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{auditLogsData.items.map((log) => (
|
||||
<AuditLogRow key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{auditLogsData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="text-sm text-secondary-500">
|
||||
Showing {(auditLogsData.page - 1) * auditLogsData.limit + 1} -{' '}
|
||||
{Math.min(
|
||||
auditLogsData.page * auditLogsData.limit,
|
||||
auditLogsData.total
|
||||
)}{' '}
|
||||
of {auditLogsData.total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(auditLogsData.page - 1)}
|
||||
disabled={auditLogsData.page === 1}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {auditLogsData.page} of {auditLogsData.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(auditLogsData.page + 1)}
|
||||
disabled={auditLogsData.page === auditLogsData.totalPages}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Activity timeline */}
|
||||
<div className="lg:col-span-2 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<ActivityTimeline
|
||||
activities={activitiesData?.items || []}
|
||||
isLoading={activitiesLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity summary */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
Activity Summary
|
||||
</h3>
|
||||
{summaryLoading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 bg-secondary-200 dark:bg-secondary-700 rounded"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : activitySummary ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
|
||||
<p className="text-sm text-primary-600 dark:text-primary-400 font-medium">
|
||||
Total Activities (30 days)
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{activitySummary.total_activities.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
By Type
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(activitySummary.by_type)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([type, count]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-secondary-600 dark:text-secondary-400 capitalize">
|
||||
{type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activitySummary.by_day && activitySummary.by_day.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Daily Trend
|
||||
</h4>
|
||||
<div className="flex items-end justify-between h-16 gap-0.5">
|
||||
{activitySummary.by_day.slice(-14).map((day) => {
|
||||
const maxCount = Math.max(
|
||||
...activitySummary.by_day.map((d) => d.count)
|
||||
);
|
||||
const height =
|
||||
maxCount > 0 ? (day.count / maxCount) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="flex-1 bg-primary-500 dark:bg-primary-400 rounded-t"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
title={`${day.date}: ${day.count}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-secondary-500">No activity data available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
src/pages/dashboard/BillingPage.tsx
Normal file
360
src/pages/dashboard/BillingPage.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import {
|
||||
useSubscription,
|
||||
useInvoices,
|
||||
usePaymentMethods,
|
||||
useStripePrices,
|
||||
useCreateBillingPortal,
|
||||
useCreateCheckoutSession,
|
||||
} from '@/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Download,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function BillingPage() {
|
||||
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
|
||||
const { data: invoicesData, isLoading: invoicesLoading } = useInvoices(1, 10);
|
||||
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
|
||||
const { data: prices } = useStripePrices();
|
||||
|
||||
const billingPortalMutation = useCreateBillingPortal();
|
||||
const checkoutMutation = useCreateCheckoutSession();
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100); // Assuming cents
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
// Handle manage billing button click
|
||||
const handleManageBilling = () => {
|
||||
billingPortalMutation.mutate(window.location.href);
|
||||
};
|
||||
|
||||
// Handle upgrade plan
|
||||
const handleUpgrade = (priceId: string) => {
|
||||
checkoutMutation.mutate({
|
||||
price_id: priceId,
|
||||
success_url: `${window.location.origin}/dashboard/billing?success=true`,
|
||||
cancel_url: `${window.location.origin}/dashboard/billing?canceled=true`,
|
||||
});
|
||||
};
|
||||
|
||||
// Get default payment method
|
||||
const defaultPaymentMethod = paymentMethods?.find((pm: any) => pm.is_default) || paymentMethods?.[0];
|
||||
|
||||
// Plans data (could come from API in the future)
|
||||
const plans = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
priceId: prices?.starter?.monthly || 'price_starter_monthly',
|
||||
features: ['Up to 5 users', '10GB storage', 'Email support', 'Basic analytics'],
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
price: '$79',
|
||||
period: '/month',
|
||||
priceId: prices?.professional?.monthly || 'price_professional_monthly',
|
||||
features: ['Up to 25 users', '100GB storage', 'Priority support', 'Advanced analytics', 'API access'],
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '$199',
|
||||
period: '/month',
|
||||
priceId: prices?.enterprise?.monthly || 'price_enterprise_monthly',
|
||||
features: ['Unlimited users', 'Unlimited storage', '24/7 support', 'Custom analytics', 'Dedicated manager'],
|
||||
},
|
||||
];
|
||||
|
||||
// Determine current plan
|
||||
const currentPlanName = subscription?.plan?.name?.toLowerCase() || 'free';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Billing & Plans
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage your subscription and billing information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Plan */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Current Plan
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{subscriptionLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : subscription ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
{subscription.plan?.display_name || subscription.plan?.name || 'Unknown Plan'}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
subscription.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: subscription.status === 'trialing'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
{subscription.plan?.price_monthly
|
||||
? formatCurrency(subscription.plan.price_monthly)
|
||||
: 'Free'}
|
||||
/month
|
||||
{subscription.current_period_end && (
|
||||
<span className="ml-2">
|
||||
· Renews on {formatDate(subscription.current_period_end)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{subscription.cancel_at_period_end && (
|
||||
<p className="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Cancels at end of billing period
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleManageBilling}
|
||||
disabled={billingPortalMutation.isPending}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{billingPortalMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Manage in Stripe
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
No active subscription. Choose a plan below to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Available Plans
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = currentPlanName === plan.name.toLowerCase();
|
||||
return (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={clsx('card', isCurrent && 'ring-2 ring-primary-500')}
|
||||
>
|
||||
<div className="card-body">
|
||||
{isCurrent && (
|
||||
<span className="inline-block px-2 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 text-xs font-medium rounded-full mb-2">
|
||||
Current Plan
|
||||
</span>
|
||||
)}
|
||||
<h3 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<span className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-secondary-500">{plan.period}</span>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{plan.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2 text-sm text-secondary-600 dark:text-secondary-400"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => !isCurrent && handleUpgrade(plan.priceId)}
|
||||
disabled={isCurrent || checkoutMutation.isPending}
|
||||
className={clsx('mt-6 w-full', isCurrent ? 'btn-secondary' : 'btn-primary')}
|
||||
>
|
||||
{checkoutMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isCurrent ? (
|
||||
'Current Plan'
|
||||
) : (
|
||||
'Upgrade'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Payment Method
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{paymentMethodsLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : defaultPaymentMethod ? (
|
||||
<div className="flex items-center gap-4 p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<div className="w-12 h-8 bg-gradient-to-r from-blue-600 to-blue-400 rounded flex items-center justify-center">
|
||||
<CreditCard className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{defaultPaymentMethod.card_brand?.charAt(0).toUpperCase() +
|
||||
defaultPaymentMethod.card_brand?.slice(1) || 'Card'}{' '}
|
||||
ending in {defaultPaymentMethod.card_last_four}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Expires {defaultPaymentMethod.card_exp_month}/{defaultPaymentMethod.card_exp_year}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleManageBilling}
|
||||
disabled={billingPortalMutation.isPending}
|
||||
className="btn-ghost text-sm"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
No payment method on file
|
||||
</p>
|
||||
<button onClick={handleManageBilling} className="btn-secondary">
|
||||
Add Payment Method
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing History */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Billing History
|
||||
</h2>
|
||||
</div>
|
||||
{invoicesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : invoicesData?.data && invoicesData.data.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-secondary-200 dark:border-secondary-700">
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Date
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Invoice
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Amount
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="py-3 px-6"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{invoicesData.data.map((invoice: any) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className="py-3 px-6 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{formatDate(invoice.issue_date)}
|
||||
</td>
|
||||
<td className="py-3 px-6 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{invoice.invoice_number}
|
||||
</td>
|
||||
<td className="py-3 px-6 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</td>
|
||||
<td className="py-3 px-6">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
invoice.status === 'paid'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: invoice.status === 'open'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: invoice.status === 'void'
|
||||
? 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-6 text-right">
|
||||
<button className="text-sm text-primary-600 hover:text-primary-500 flex items-center gap-1">
|
||||
<Download className="w-4 h-4" />
|
||||
PDF
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-secondary-500 dark:text-secondary-400">
|
||||
<CreditCard className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No invoices yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/pages/dashboard/DashboardPage.tsx
Normal file
228
src/pages/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useUsers, useSubscription, useBillingSummary, useUnreadNotificationsCount } from '@/hooks';
|
||||
import { Users, CreditCard, Activity, TrendingUp, Bell, Loader2 } from 'lucide-react';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { data: usersData, isLoading: usersLoading } = useUsers(1, 10);
|
||||
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
|
||||
const { data: billingSummary, isLoading: billingLoading } = useBillingSummary();
|
||||
const { data: unreadCount } = useUnreadNotificationsCount();
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount / 100); // Assuming cents
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Total Users',
|
||||
value: usersLoading ? '...' : (usersData?.total ?? 0).toString(),
|
||||
icon: Users,
|
||||
change: '+12%',
|
||||
changeType: 'positive' as const,
|
||||
},
|
||||
{
|
||||
name: 'Monthly Revenue',
|
||||
value: billingLoading ? '...' : formatCurrency(billingSummary?.totalPaid ?? 0),
|
||||
icon: CreditCard,
|
||||
change: '+8%',
|
||||
changeType: 'positive' as const,
|
||||
},
|
||||
{
|
||||
name: 'Current Plan',
|
||||
value: subscriptionLoading ? '...' : (subscription?.plan?.display_name ?? 'Free'),
|
||||
icon: TrendingUp,
|
||||
change: subscription?.status === 'active' ? 'Active' : (subscription?.status ?? 'N/A'),
|
||||
changeType: subscription?.status === 'active' ? 'positive' as const : 'neutral' as const,
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
value: (unreadCount?.count ?? 0).toString(),
|
||||
icon: Bell,
|
||||
change: unreadCount?.count ? 'Unread' : 'All read',
|
||||
changeType: unreadCount?.count ? 'neutral' as const : 'positive' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Welcome back, {user?.first_name || 'User'}!
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Here's what's happening with your business today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="card card-body">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<stat.icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
stat.changeType === 'positive'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-secondary-500 dark:text-secondary-400'
|
||||
}`}
|
||||
>
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{stat.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Quick Actions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Link to="/dashboard/users" className="btn-secondary justify-center py-3">
|
||||
<Users className="w-5 h-5 mr-2" />
|
||||
Manage Team
|
||||
</Link>
|
||||
<Link to="/dashboard/billing" className="btn-secondary justify-center py-3">
|
||||
<CreditCard className="w-5 h-5 mr-2" />
|
||||
Manage Billing
|
||||
</Link>
|
||||
<Link to="/dashboard/settings" className="btn-secondary justify-center py-3">
|
||||
<Activity className="w-5 h-5 mr-2" />
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent users */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Recent Team Members
|
||||
</h2>
|
||||
<Link
|
||||
to="/dashboard/users"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{usersLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : usersData?.data && usersData.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{usersData.data.slice(0, 5).map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-secondary-50 dark:bg-secondary-700/30"
|
||||
>
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
{(u.first_name?.[0] || u.email[0]).toUpperCase()}
|
||||
{(u.last_name?.[0] || '').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900 dark:text-white truncate">
|
||||
{u.first_name && u.last_name
|
||||
? `${u.first_name} ${u.last_name}`
|
||||
: u.email}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 truncate">{u.email}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||
u.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{u.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No team members yet</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription info */}
|
||||
{subscription && (
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Subscription
|
||||
</h2>
|
||||
<Link
|
||||
to="/dashboard/billing"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
{subscription.plan?.display_name || 'Unknown Plan'}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
subscription.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
{subscription.plan?.price_monthly
|
||||
? formatCurrency(subscription.plan.price_monthly)
|
||||
: 'Free'}
|
||||
/month
|
||||
{subscription.current_period_end && (
|
||||
<span className="ml-2">
|
||||
· Renews {new Date(subscription.current_period_end).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
src/pages/dashboard/FeatureFlagsPage.tsx
Normal file
389
src/pages/dashboard/FeatureFlagsPage.tsx
Normal file
@ -0,0 +1,389 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { FeatureFlag, CreateFlagRequest, UpdateFlagRequest } from '@/services/api';
|
||||
import {
|
||||
useFeatureFlags,
|
||||
useCreateFeatureFlag,
|
||||
useUpdateFeatureFlag,
|
||||
useDeleteFeatureFlag,
|
||||
useToggleFeatureFlag,
|
||||
useTenantFlagOverrides,
|
||||
useSetTenantFlagOverride,
|
||||
useRemoveTenantFlagOverride,
|
||||
} from '@/hooks/useFeatureFlags';
|
||||
import {
|
||||
FeatureFlagCard,
|
||||
FeatureFlagForm,
|
||||
TenantOverridesPanel,
|
||||
} from '@/components/feature-flags';
|
||||
import {
|
||||
Flag,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Search,
|
||||
ToggleLeft,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type ViewMode = 'list' | 'create' | 'edit' | 'overrides';
|
||||
|
||||
export function FeatureFlagsPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedFlag, setSelectedFlag] = useState<FeatureFlag | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<FeatureFlag | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
const [showEnabledOnly, setShowEnabledOnly] = useState(false);
|
||||
|
||||
// Queries
|
||||
const { data: flags = [], isLoading: flagsLoading } = useFeatureFlags();
|
||||
const { data: tenantOverrides = [] } = useTenantFlagOverrides();
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateFeatureFlag();
|
||||
const updateMutation = useUpdateFeatureFlag();
|
||||
const deleteMutation = useDeleteFeatureFlag();
|
||||
const toggleMutation = useToggleFeatureFlag();
|
||||
const setOverrideMutation = useSetTenantFlagOverride();
|
||||
const removeOverrideMutation = useRemoveTenantFlagOverride();
|
||||
|
||||
// Get unique categories
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
flags.forEach((flag) => {
|
||||
if (flag.category) cats.add(flag.category);
|
||||
});
|
||||
return Array.from(cats).sort();
|
||||
}, [flags]);
|
||||
|
||||
// Filter flags
|
||||
const filteredFlags = useMemo(() => {
|
||||
return flags.filter((flag) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
flag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
flag.key.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = !categoryFilter || flag.category === categoryFilter;
|
||||
const matchesEnabled = !showEnabledOnly || flag.is_enabled;
|
||||
return matchesSearch && matchesCategory && matchesEnabled;
|
||||
});
|
||||
}, [flags, searchQuery, categoryFilter, showEnabledOnly]);
|
||||
|
||||
const handleCreate = async (data: CreateFlagRequest) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
setViewMode('list');
|
||||
} catch (error) {
|
||||
console.error('Failed to create flag:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: UpdateFlagRequest) => {
|
||||
if (!selectedFlag) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: selectedFlag.id, data });
|
||||
setViewMode('list');
|
||||
setSelectedFlag(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update flag:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.id);
|
||||
setDeleteConfirm(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete flag:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (flag: FeatureFlag) => {
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ id: flag.id, enabled: !flag.is_enabled });
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle flag:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOverride = async (flagId: string, isEnabled: boolean, value?: any) => {
|
||||
try {
|
||||
await setOverrideMutation.mutateAsync({
|
||||
flag_id: flagId,
|
||||
is_enabled: isEnabled,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add override:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOverride = async (flagId: string) => {
|
||||
try {
|
||||
await removeOverrideMutation.mutateAsync(flagId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove override:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render list view
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Feature Flags
|
||||
</h1>
|
||||
<p className="text-secondary-500 mt-1">
|
||||
Manage feature flags and rollouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setViewMode('overrides')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-secondary-700 dark:text-secondary-300 border border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Tenant Overrides
|
||||
{tenantOverrides.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 text-xs rounded-full">
|
||||
{tenantOverrides.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Flag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px] max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search flags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowEnabledOnly(!showEnabledOnly)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border',
|
||||
showEnabledOnly
|
||||
? 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400'
|
||||
: 'border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700'
|
||||
)}
|
||||
>
|
||||
<ToggleLeft className="w-4 h-4" />
|
||||
Enabled Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Flags grid */}
|
||||
{flagsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
|
||||
</div>
|
||||
) : filteredFlags.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Flag className="w-12 h-12 mx-auto text-secondary-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">
|
||||
{flags.length === 0 ? 'No feature flags yet' : 'No matching flags'}
|
||||
</h3>
|
||||
<p className="text-secondary-500 mb-4">
|
||||
{flags.length === 0
|
||||
? 'Create your first feature flag to start managing features'
|
||||
: 'Try adjusting your search or filters'}
|
||||
</p>
|
||||
{flags.length === 0 && (
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Create Flag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredFlags.map((flag) => (
|
||||
<FeatureFlagCard
|
||||
key={flag.id}
|
||||
flag={flag}
|
||||
onEdit={(f) => {
|
||||
setSelectedFlag(f);
|
||||
setViewMode('edit');
|
||||
}}
|
||||
onDelete={setDeleteConfirm}
|
||||
onToggle={handleToggle}
|
||||
isToggling={toggleMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/20 rounded-full">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Delete Feature Flag
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Are you sure you want to delete "{deleteConfirm.name}"?
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 mb-6">
|
||||
This will remove the flag globally and all tenant/user overrides will be lost.
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render create view
|
||||
if (viewMode === 'create') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Create Feature Flag
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<FeatureFlagForm
|
||||
onSubmit={(data) => handleCreate(data as CreateFlagRequest)}
|
||||
onCancel={() => setViewMode('list')}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render edit view
|
||||
if (viewMode === 'edit' && selectedFlag) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedFlag(null);
|
||||
}}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Edit Feature Flag
|
||||
</h1>
|
||||
<code className="text-sm text-secondary-500">{selectedFlag.key}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<FeatureFlagForm
|
||||
flag={selectedFlag}
|
||||
onSubmit={(data) => handleUpdate(data as UpdateFlagRequest)}
|
||||
onCancel={() => {
|
||||
setViewMode('list');
|
||||
setSelectedFlag(null);
|
||||
}}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render overrides view
|
||||
if (viewMode === 'overrides') {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Tenant Overrides
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<TenantOverridesPanel
|
||||
flags={flags}
|
||||
overrides={tenantOverrides}
|
||||
onAdd={handleAddOverride}
|
||||
onRemove={handleRemoveOverride}
|
||||
isLoading={setOverrideMutation.isPending || removeOverrideMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
198
src/pages/dashboard/SettingsPage.tsx
Normal file
198
src/pages/dashboard/SettingsPage.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAuthStore, useUIStore } from '@/stores';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Loader2, User, Building, Moon, Sun, Monitor } from 'lucide-react';
|
||||
|
||||
interface ProfileFormData {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { theme, setTheme } = useUIStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ProfileFormData>({
|
||||
defaultValues: {
|
||||
first_name: user?.first_name || '',
|
||||
last_name: user?.last_name || '',
|
||||
email: user?.email || '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (_data: ProfileFormData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// API call would go here
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('Profile updated successfully!');
|
||||
} catch {
|
||||
toast.error('Failed to update profile');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center gap-3">
|
||||
<User className="w-5 h-5 text-secondary-500" />
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Profile Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="label">
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
className="input"
|
||||
{...register('first_name', { required: 'First name is required' })}
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.first_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="last_name" className="label">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
className="input"
|
||||
{...register('last_name', { required: 'Last name is required' })}
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.last_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="input"
|
||||
disabled
|
||||
{...register('email')}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
Contact support to change your email address
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button type="submit" disabled={isLoading} className="btn-primary">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Settings */}
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center gap-3">
|
||||
<Building className="w-5 h-5 text-secondary-500" />
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Organization
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
Tenant ID
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-400 font-mono">
|
||||
{user?.tenant_id || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Appearance
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{themes.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTheme(t.value)}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
|
||||
theme === t.value
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-secondary-200 dark:border-secondary-700 hover:border-secondary-300 dark:hover:border-secondary-600'
|
||||
}`}
|
||||
>
|
||||
<t.icon
|
||||
className={`w-6 h-6 ${
|
||||
theme === t.value
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-secondary-500'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
theme === t.value
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-secondary-700 dark:text-secondary-300'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/pages/dashboard/StoragePage.tsx
Normal file
91
src/pages/dashboard/StoragePage.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import { FileUpload, FileList, StorageUsageCard } from '@/components/storage';
|
||||
import { StorageFile } from '@/services/api';
|
||||
|
||||
export function StoragePage() {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<StorageFile | null>(null);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage your files and documents
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<FileList onPreview={setPreviewFile} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<StorageUsageCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Upload File</h2>
|
||||
<button
|
||||
onClick={() => setShowUpload(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<FileUpload
|
||||
onSuccess={() => {
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
{previewFile && previewFile.mimeType.startsWith('image/') && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div className="relative max-w-4xl max-h-full">
|
||||
<button
|
||||
onClick={() => setPreviewFile(null)}
|
||||
className="absolute -top-10 right-0 text-white hover:text-gray-300"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<img
|
||||
src={previewFile.thumbnails?.large || previewFile.thumbnails?.medium || ''}
|
||||
alt={previewFile.originalName}
|
||||
className="max-w-full max-h-[80vh] object-contain rounded-lg"
|
||||
/>
|
||||
<div className="text-white text-center mt-4">
|
||||
<p className="font-medium">{previewFile.originalName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
src/pages/dashboard/UsersPage.tsx
Normal file
384
src/pages/dashboard/UsersPage.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useUsers, useInviteUser, UserListItem } from '@/hooks';
|
||||
import { ExportButton } from '@/components/common';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreVertical,
|
||||
Mail,
|
||||
Shield,
|
||||
Trash2,
|
||||
Loader2,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface InviteFormData {
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const limit = 10;
|
||||
|
||||
const { data: usersData, isLoading, isError, refetch } = useUsers(page, limit);
|
||||
const inviteMutation = useInviteUser();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<InviteFormData>({
|
||||
defaultValues: { role: 'member' },
|
||||
});
|
||||
|
||||
// Filter users by search (client-side for now)
|
||||
const filteredUsers = usersData?.data?.filter(
|
||||
(user: UserListItem) =>
|
||||
user.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(user.first_name?.toLowerCase() || '').includes(search.toLowerCase()) ||
|
||||
(user.last_name?.toLowerCase() || '').includes(search.toLowerCase())
|
||||
) ?? [];
|
||||
|
||||
const totalPages = usersData?.totalPages ?? 1;
|
||||
|
||||
const handleInvite = (data: InviteFormData) => {
|
||||
inviteMutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
setShowInviteModal(false);
|
||||
reset();
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hours ago`;
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getUserInitials = (user: UserListItem) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
return user.email[0].toUpperCase();
|
||||
};
|
||||
|
||||
const getUserDisplayName = (user: UserListItem) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`;
|
||||
}
|
||||
return user.email.split('@')[0];
|
||||
};
|
||||
|
||||
const getUserRole = (user: UserListItem) => {
|
||||
return user.roles?.[0]?.name || 'Member';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Team Members
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage your team and their permissions
|
||||
{usersData?.total !== undefined && (
|
||||
<span className="ml-2">({usersData.total} total)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExportButton reportType="users" />
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invite Member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
className="input pl-10"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-red-500 mb-4">Failed to load users</p>
|
||||
<button onClick={() => refetch()} className="btn-secondary">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-secondary-50 dark:bg-secondary-700/50">
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
User
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Role
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="py-3 px-6 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{filteredUsers.map((user: UserListItem) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700/30"
|
||||
>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
{getUserInitials(user)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{getUserDisplayName(user)}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
getUserRole(user).toLowerCase() === 'admin' ||
|
||||
getUserRole(user).toLowerCase() === 'owner'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300'
|
||||
)}
|
||||
>
|
||||
{(getUserRole(user).toLowerCase() === 'admin' ||
|
||||
getUserRole(user).toLowerCase() === 'owner') && (
|
||||
<Shield className="w-3 h-3" />
|
||||
)}
|
||||
{getUserRole(user)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
user.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: user.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{formatDate(user.last_login_at)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
setOpenMenu(openMenu === user.id ? null : user.id)
|
||||
}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-secondary-500" />
|
||||
</button>
|
||||
|
||||
{openMenu === user.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setOpenMenu(null)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
|
||||
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700">
|
||||
<Mail className="w-4 h-4" />
|
||||
Send email
|
||||
</button>
|
||||
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700">
|
||||
<Shield className="w-4 h-4" />
|
||||
Change role
|
||||
</button>
|
||||
<hr className="my-1 border-secondary-200 dark:border-secondary-700" />
|
||||
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="py-12 text-center text-secondary-500 dark:text-secondary-400">
|
||||
{search
|
||||
? 'No users found matching your search.'
|
||||
: 'No team members yet. Invite someone to get started!'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="btn-secondary px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="btn-secondary px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
/>
|
||||
<div className="relative bg-white dark:bg-secondary-800 rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Invite Team Member
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<X className="w-5 h-5 text-secondary-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(handleInvite)} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="colleague@company.com"
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="label">
|
||||
Role
|
||||
</label>
|
||||
<select id="role" className="input" {...register('role')}>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{inviteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send Invitation'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/pages/dashboard/WebhooksPage.tsx
Normal file
308
src/pages/dashboard/WebhooksPage.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
import { useState } from 'react';
|
||||
import { Webhook } from '@/services/api';
|
||||
import {
|
||||
useWebhooks,
|
||||
useWebhookEvents,
|
||||
useWebhookDeliveries,
|
||||
useCreateWebhook,
|
||||
useUpdateWebhook,
|
||||
useDeleteWebhook,
|
||||
useToggleWebhook,
|
||||
useTestWebhook,
|
||||
useRetryDelivery,
|
||||
} from '@/hooks';
|
||||
import { WebhookCard, WebhookForm, WebhookDeliveryList } from '@/components/webhooks';
|
||||
import { Plus, ArrowLeft, Webhook as WebhookIcon, AlertTriangle } from 'lucide-react';
|
||||
|
||||
type ViewMode = 'list' | 'create' | 'edit' | 'deliveries';
|
||||
|
||||
export function WebhooksPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Webhook | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: webhooks = [], isLoading: webhooksLoading } = useWebhooks();
|
||||
const { data: events = [] } = useWebhookEvents();
|
||||
const { data: deliveriesData, isLoading: deliveriesLoading } = useWebhookDeliveries(
|
||||
selectedWebhook?.id || '',
|
||||
{ limit: 20 }
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateWebhook();
|
||||
const updateMutation = useUpdateWebhook();
|
||||
const deleteMutation = useDeleteWebhook();
|
||||
const toggleMutation = useToggleWebhook();
|
||||
const testMutation = useTestWebhook();
|
||||
const retryMutation = useRetryDelivery();
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
setViewMode('list');
|
||||
} catch (error) {
|
||||
console.error('Failed to create webhook:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: any) => {
|
||||
if (!selectedWebhook) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: selectedWebhook.id, data });
|
||||
setViewMode('list');
|
||||
setSelectedWebhook(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update webhook:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.id);
|
||||
setDeleteConfirm(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete webhook:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (webhook: Webhook) => {
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ id: webhook.id, isActive: !webhook.isActive });
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle webhook:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (webhook: Webhook) => {
|
||||
try {
|
||||
await testMutation.mutateAsync({ id: webhook.id });
|
||||
setSelectedWebhook(webhook);
|
||||
setViewMode('deliveries');
|
||||
} catch (error) {
|
||||
console.error('Failed to test webhook:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (deliveryId: string) => {
|
||||
if (!selectedWebhook) return;
|
||||
try {
|
||||
await retryMutation.mutateAsync({ webhookId: selectedWebhook.id, deliveryId });
|
||||
} catch (error) {
|
||||
console.error('Failed to retry delivery:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render list view
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Webhooks
|
||||
</h1>
|
||||
<p className="text-secondary-500 mt-1">
|
||||
Manage outbound webhooks for your integrations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{webhooksLoading ? (
|
||||
<div className="text-center py-12 text-secondary-500">Loading webhooks...</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<WebhookIcon className="w-12 h-12 mx-auto text-secondary-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">
|
||||
No webhooks configured
|
||||
</h3>
|
||||
<p className="text-secondary-500 mb-4">
|
||||
Create your first webhook to start receiving event notifications
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Create Webhook
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{webhooks.map((webhook) => (
|
||||
<WebhookCard
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
onEdit={(w) => {
|
||||
setSelectedWebhook(w);
|
||||
setViewMode('edit');
|
||||
}}
|
||||
onDelete={setDeleteConfirm}
|
||||
onTest={handleTest}
|
||||
onToggle={handleToggle}
|
||||
onViewDeliveries={(w) => {
|
||||
setSelectedWebhook(w);
|
||||
setViewMode('deliveries');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/20 rounded-full">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Delete Webhook
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Are you sure you want to delete "{deleteConfirm.name}"? This action cannot be
|
||||
undone and all delivery history will be lost.
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render create view
|
||||
if (viewMode === 'create') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Create Webhook
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<WebhookForm
|
||||
events={events}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setViewMode('list')}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render edit view
|
||||
if (viewMode === 'edit' && selectedWebhook) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedWebhook(null);
|
||||
}}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Edit Webhook
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<WebhookForm
|
||||
webhook={selectedWebhook}
|
||||
events={events}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => {
|
||||
setViewMode('list');
|
||||
setSelectedWebhook(null);
|
||||
}}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render deliveries view
|
||||
if (viewMode === 'deliveries' && selectedWebhook) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedWebhook(null);
|
||||
}}
|
||||
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{selectedWebhook.name}
|
||||
</h1>
|
||||
<p className="text-secondary-500">{selectedWebhook.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
Delivery History
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleTest(selectedWebhook)}
|
||||
disabled={testMutation.isPending}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<WebhookDeliveryList
|
||||
deliveries={deliveriesData?.items || []}
|
||||
isLoading={deliveriesLoading}
|
||||
onRetry={handleRetry}
|
||||
hasMore={(deliveriesData?.page || 1) < (deliveriesData?.totalPages || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
9
src/pages/dashboard/index.ts
Normal file
9
src/pages/dashboard/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from './DashboardPage';
|
||||
export * from './SettingsPage';
|
||||
export * from './BillingPage';
|
||||
export * from './UsersPage';
|
||||
export * from './AIPage';
|
||||
export * from './StoragePage';
|
||||
export * from './WebhooksPage';
|
||||
export * from './AuditLogsPage';
|
||||
export * from './FeatureFlagsPage';
|
||||
233
src/pages/onboarding/OnboardingPage.tsx
Normal file
233
src/pages/onboarding/OnboardingPage.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Check, Building2, Users, CreditCard, Rocket } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
useOnboarding,
|
||||
usePlans,
|
||||
useUpdateCompany,
|
||||
useInviteUsers,
|
||||
useCompleteOnboarding,
|
||||
type OnboardingStep,
|
||||
type CompanyData,
|
||||
type InvitedUser,
|
||||
} from '@/hooks/useOnboarding';
|
||||
import { CompanyStep, InviteStep, PlanStep, CompleteStep } from './steps';
|
||||
|
||||
const STEP_CONFIG: Record<OnboardingStep, { icon: typeof Building2; label: string; color: string }> = {
|
||||
company: { icon: Building2, label: 'Company', color: 'primary' },
|
||||
invite: { icon: Users, label: 'Team', color: 'green' },
|
||||
plan: { icon: CreditCard, label: 'Plan', color: 'purple' },
|
||||
complete: { icon: Rocket, label: 'Launch', color: 'amber' },
|
||||
};
|
||||
|
||||
export function OnboardingPage() {
|
||||
const {
|
||||
state,
|
||||
updateState,
|
||||
nextStep,
|
||||
prevStep,
|
||||
canGoNext,
|
||||
canGoPrev,
|
||||
getStepIndex,
|
||||
getTotalSteps,
|
||||
isStepCompleted,
|
||||
steps,
|
||||
} = useOnboarding();
|
||||
|
||||
const { data: plans, isLoading: plansLoading } = usePlans();
|
||||
const updateCompanyMutation = useUpdateCompany();
|
||||
const inviteUsersMutation = useInviteUsers();
|
||||
const completeOnboardingMutation = useCompleteOnboarding();
|
||||
|
||||
// Handlers
|
||||
const handleCompanyUpdate = useCallback((data: CompanyData) => {
|
||||
updateState({ companyData: data });
|
||||
}, [updateState]);
|
||||
|
||||
const handleInvitedUsersUpdate = useCallback((users: InvitedUser[]) => {
|
||||
updateState({ invitedUsers: users });
|
||||
}, [updateState]);
|
||||
|
||||
const handleSendInvites = useCallback(async (users: { email: string; role: string }[]) => {
|
||||
await inviteUsersMutation.mutateAsync(users);
|
||||
}, [inviteUsersMutation]);
|
||||
|
||||
const handlePlanSelect = useCallback((planId: string) => {
|
||||
updateState({ selectedPlanId: planId });
|
||||
}, [updateState]);
|
||||
|
||||
const handleComplete = useCallback(async () => {
|
||||
// Save company data if changed
|
||||
if (state.companyData) {
|
||||
await updateCompanyMutation.mutateAsync(state.companyData);
|
||||
}
|
||||
// Complete onboarding
|
||||
await completeOnboardingMutation.mutateAsync();
|
||||
}, [state.companyData, updateCompanyMutation, completeOnboardingMutation]);
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
// Save progress before moving to next step
|
||||
if (state.currentStep === 'company' && state.companyData) {
|
||||
try {
|
||||
await updateCompanyMutation.mutateAsync(state.companyData);
|
||||
} catch {
|
||||
// Continue anyway, data is saved locally
|
||||
}
|
||||
}
|
||||
nextStep();
|
||||
}, [state.currentStep, state.companyData, updateCompanyMutation, nextStep]);
|
||||
|
||||
const currentStepIndex = getStepIndex();
|
||||
const totalSteps = getTotalSteps();
|
||||
const progress = ((currentStepIndex + 1) / totalSteps) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-secondary-50 to-secondary-100 dark:from-secondary-900 dark:to-secondary-800">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
|
||||
<div className="text-sm text-secondary-500">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-between py-6">
|
||||
{steps.map((step, index) => {
|
||||
const config = STEP_CONFIG[step];
|
||||
const Icon = config.icon;
|
||||
const isActive = state.currentStep === step;
|
||||
const isCompleted = isStepCompleted(step) || index < currentStepIndex;
|
||||
const isPast = index < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center flex-1">
|
||||
{/* Step circle */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-12 h-12 rounded-full flex items-center justify-center transition-all',
|
||||
isCompleted && 'bg-green-500 text-white',
|
||||
isActive && !isCompleted && 'bg-primary-600 text-white ring-4 ring-primary-100 dark:ring-primary-900',
|
||||
!isActive && !isCompleted && 'bg-secondary-200 dark:bg-secondary-700 text-secondary-500'
|
||||
)}
|
||||
>
|
||||
{isCompleted && !isActive ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : (
|
||||
<Icon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-2 text-sm font-medium',
|
||||
isActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-secondary-500'
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 h-1 mx-4 rounded-full transition-colors',
|
||||
isPast ? 'bg-green-500' : 'bg-secondary-200 dark:bg-secondary-700'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden mb-4">
|
||||
<div
|
||||
className="h-full bg-primary-600 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-2xl shadow-xl p-8">
|
||||
{/* Step content */}
|
||||
{state.currentStep === 'company' && (
|
||||
<CompanyStep
|
||||
data={state.companyData}
|
||||
onUpdate={handleCompanyUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 'invite' && (
|
||||
<InviteStep
|
||||
invitedUsers={state.invitedUsers}
|
||||
onUpdate={handleInvitedUsersUpdate}
|
||||
onSendInvites={handleSendInvites}
|
||||
isSending={inviteUsersMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 'plan' && (
|
||||
<PlanStep
|
||||
plans={plans || []}
|
||||
selectedPlanId={state.selectedPlanId}
|
||||
onSelect={handlePlanSelect}
|
||||
isLoading={plansLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
state={state}
|
||||
onComplete={handleComplete}
|
||||
isCompleting={completeOnboardingMutation.isPending || updateCompanyMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{state.currentStep !== 'complete' && (
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={!canGoPrev()}
|
||||
className={clsx(
|
||||
'px-6 py-3 rounded-lg font-medium transition-colors',
|
||||
canGoPrev()
|
||||
? 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
|
||||
: 'text-secondary-300 dark:text-secondary-600 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext() && state.currentStep !== 'invite'}
|
||||
className={clsx(
|
||||
'px-8 py-3 rounded-lg font-medium transition-colors',
|
||||
canGoNext() || state.currentStep === 'invite'
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{state.currentStep === 'plan' ? 'Complete Setup' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/pages/onboarding/index.ts
Normal file
1
src/pages/onboarding/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { OnboardingPage } from './OnboardingPage';
|
||||
233
src/pages/onboarding/steps/CompanyStep.tsx
Normal file
233
src/pages/onboarding/steps/CompanyStep.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Building2, Globe, Image, Users, Clock } from 'lucide-react';
|
||||
import type { CompanyData } from '@/hooks/useOnboarding';
|
||||
|
||||
interface CompanyStepProps {
|
||||
data: CompanyData | null;
|
||||
onUpdate: (data: CompanyData) => void;
|
||||
}
|
||||
|
||||
const INDUSTRIES = [
|
||||
'Technology',
|
||||
'Healthcare',
|
||||
'Finance',
|
||||
'Education',
|
||||
'Retail',
|
||||
'Manufacturing',
|
||||
'Consulting',
|
||||
'Media',
|
||||
'Real Estate',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const COMPANY_SIZES = [
|
||||
{ value: '1-10', label: '1-10 employees' },
|
||||
{ value: '11-50', label: '11-50 employees' },
|
||||
{ value: '51-200', label: '51-200 employees' },
|
||||
{ value: '201-500', label: '201-500 employees' },
|
||||
{ value: '500+', label: '500+ employees' },
|
||||
];
|
||||
|
||||
const TIMEZONES = [
|
||||
{ value: 'America/New_York', label: 'Eastern Time (ET)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (CT)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (MT)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
|
||||
{ value: 'America/Mexico_City', label: 'Mexico City' },
|
||||
{ value: 'Europe/London', label: 'London (GMT)' },
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (CET)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
];
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 50);
|
||||
}
|
||||
|
||||
export function CompanyStep({ data, onUpdate }: CompanyStepProps) {
|
||||
const [formData, setFormData] = useState<CompanyData>({
|
||||
name: data?.name || '',
|
||||
slug: data?.slug || '',
|
||||
domain: data?.domain || '',
|
||||
logo_url: data?.logo_url || '',
|
||||
industry: data?.industry || '',
|
||||
size: data?.size || '',
|
||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Auto-generate slug from name
|
||||
useEffect(() => {
|
||||
if (!slugManuallyEdited && formData.name) {
|
||||
setFormData((prev) => ({ ...prev, slug: generateSlug(formData.name) }));
|
||||
}
|
||||
}, [formData.name, slugManuallyEdited]);
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
onUpdate(formData);
|
||||
}, [formData, onUpdate]);
|
||||
|
||||
const handleChange = (field: keyof CompanyData, value: string) => {
|
||||
if (field === 'slug') {
|
||||
setSlugManuallyEdited(true);
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full mb-4">
|
||||
<Building2 className="w-8 h-8 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
Tell us about your company
|
||||
</h2>
|
||||
<p className="text-secondary-500 mt-2">
|
||||
This information helps us customize your experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Company Name */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Company Name *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Acme Corporation"
|
||||
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Workspace URL *
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<span className="px-3 py-3 bg-secondary-100 dark:bg-secondary-700 border border-r-0 border-secondary-300 dark:border-secondary-600 rounded-l-lg text-secondary-500 text-sm">
|
||||
app.example.com/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => handleChange('slug', generateSlug(e.target.value))}
|
||||
placeholder="acme"
|
||||
className="flex-1 px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-r-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
Only lowercase letters, numbers, and hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Company Domain
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.domain}
|
||||
onChange={(e) => handleChange('domain', e.target.value)}
|
||||
placeholder="acme.com"
|
||||
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo URL */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Logo URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Image className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="url"
|
||||
value={formData.logo_url}
|
||||
onChange={(e) => handleChange('logo_url', e.target.value)}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
You can upload a logo later in settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Industry */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Industry
|
||||
</label>
|
||||
<select
|
||||
value={formData.industry}
|
||||
onChange={(e) => handleChange('industry', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select industry</option>
|
||||
{INDUSTRIES.map((industry) => (
|
||||
<option key={industry} value={industry}>
|
||||
{industry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Company Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<Users className="inline w-4 h-4 mr-1" />
|
||||
Company Size
|
||||
</label>
|
||||
<select
|
||||
value={formData.size}
|
||||
onChange={(e) => handleChange('size', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select size</option>
|
||||
{COMPANY_SIZES.map((size) => (
|
||||
<option key={size.value} value={size.value}>
|
||||
{size.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<Clock className="inline w-4 h-4 mr-1" />
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
value={formData.timezone}
|
||||
onChange={(e) => handleChange('timezone', e.target.value)}
|
||||
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user