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