Commit inicial - upload de todos os arquivos da pasta
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
214
Deploy-Coolify.md
Normal file
@@ -0,0 +1,214 @@
|
||||
Sim, dá para usar **Nixpacks**. Você **não deve compactar `node_modules`, `dist`, `.git`, logs, cache, uploads grandes**.
|
||||
|
||||
Compacte somente o código-fonte necessário:
|
||||
|
||||
```txt
|
||||
meu-projeto/
|
||||
├─ frontend/
|
||||
│ ├─ package.json
|
||||
│ ├─ package-lock.json
|
||||
│ ├─ index.html
|
||||
│ ├─ vite.config.*
|
||||
│ ├─ src/
|
||||
│ └─ public/
|
||||
tsconfig.json
|
||||
tsconfig.app.json
|
||||
tsconfig.node.json
|
||||
tailwind.config.ts
|
||||
postcss.config.js
|
||||
eslint.config.js
|
||||
.env.example
|
||||
README.md
|
||||
|
||||
|
||||
|
||||
|
||||
├─ backend/
|
||||
│ ├─ package.json
|
||||
│ ├─ package-lock.json
|
||||
│ ├─ src/
|
||||
│ └─ .env.example
|
||||
prisma
|
||||
nest-cli.json
|
||||
tsconfig.build.json
|
||||
tsconfig.json
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
Ignore no `.zip`:
|
||||
|
||||
```txt
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.cache/
|
||||
.vite/
|
||||
coverage/
|
||||
uploads/
|
||||
tmp/
|
||||
```
|
||||
|
||||
## Recomendado no Coolify
|
||||
|
||||
Crie **2 aplicações separadas**:
|
||||
|
||||
```txt
|
||||
App 1: frontend React + Vite
|
||||
App 2: backend API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. Deploy do Backend
|
||||
|
||||
No Coolify:
|
||||
|
||||
**Source:** Upload ZIP ou Git
|
||||
**Build Pack:** Nixpacks
|
||||
**Base Directory:** `backend`
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Build Command, se usar TypeScript:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start Command:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
Se o backend roda em dev com `npm run dev`, crie um script de produção no `backend/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ou, se for JavaScript puro:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No backend, use a porta via variável:
|
||||
|
||||
```js
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, "0.0.0.0");
|
||||
```
|
||||
|
||||
No Coolify, configure as variáveis de ambiente do backend, exemplo:
|
||||
|
||||
```env
|
||||
DATABASE_URL=...
|
||||
JWT_SECRET=...
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. Deploy do Frontend React + Vite
|
||||
|
||||
No Coolify:
|
||||
|
||||
**Build Pack:** Nixpacks
|
||||
**Base Directory:** `frontend`
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Build Command:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start Command:
|
||||
|
||||
```bash
|
||||
npm run preview -- --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
No `frontend/package.json`, deixe assim:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variável para apontar para o backend:
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://api.seudominio.com
|
||||
```
|
||||
|
||||
No código React:
|
||||
|
||||
```js
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. O que compactar
|
||||
|
||||
Compacte a pasta raiz do projeto contendo `frontend` e `backend`, mas sem arquivos pesados.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```bash
|
||||
zip -r projeto.zip . \
|
||||
-x "*/node_modules/*" \
|
||||
-x "*/dist/*" \
|
||||
-x "*/build/*" \
|
||||
-x ".git/*" \
|
||||
-x "*/.env*" \
|
||||
-x "*/.cache/*"
|
||||
```
|
||||
|
||||
Melhor ainda: suba no **GitHub/GitLab** e conecte o repositório no Coolify. É mais fácil de atualizar depois.
|
||||
|
||||
Configuração da imagem:
|
||||
|
||||
Frontend:
|
||||
|
||||
```txt
|
||||
Install Command: npm install
|
||||
Build Command: npm run build
|
||||
Start Command: npm run preview -- --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
Backend:
|
||||
|
||||
```txt
|
||||
Install Command: npm install
|
||||
Build Command: npm run build
|
||||
Start Command: npm run start
|
||||
```
|
||||
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
test
|
||||
37
eslint.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.ts'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
29
github.bat
Normal file
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
echo === INICIANDO UPLOAD PARA GITHUB ===
|
||||
|
||||
REM Inicializar repositório Git
|
||||
echo Inicializando repositorio Git...
|
||||
git init
|
||||
|
||||
REM Adicionar todos os arquivos
|
||||
echo Adicionando todos os arquivos...
|
||||
git add .
|
||||
|
||||
REM Fazer commit inicial
|
||||
echo Realizando commit inicial...
|
||||
git commit -m "Commit inicial - upload de todos os arquivos da pasta"
|
||||
|
||||
REM Adicionar repositório remoto
|
||||
echo Conectando ao repositorio remoto...
|
||||
git remote add origin https://gitea.aplicativopro.com/wander/Frontend-Iasis.git
|
||||
|
||||
REM Definir branch principal
|
||||
echo Definindo branch principal como 'main'...
|
||||
git branch -M main
|
||||
|
||||
REM Fazer push para o GitHub
|
||||
echo Fazendo upload para o GitHub...
|
||||
git push -u origin main --force
|
||||
|
||||
echo === UPLOAD CONCLUIDO COM SUCESSO! ===
|
||||
pause
|
||||
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>Gestão ISIS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5531
package-lock.json
generated
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "iasis-gestao-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"axios": "^1.16.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"postcss": "^8.5.8",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/logo/logo-full.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/logo/logo-icon.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
6
public/staticwebapp.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html",
|
||||
"exclude": ["/assets/*"]
|
||||
}
|
||||
}
|
||||
0
src/app/layouts/.gitkeep
Normal file
0
src/app/providers/.gitkeep
Normal file
22
src/app/providers/AppProviders.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AuthProvider } from './AuthProvider';
|
||||
import { QueryProvider } from './QueryProvider';
|
||||
import { SidebarProvider } from './SidebarProvider';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { ToastProvider } from '../../components/ui/Toast';
|
||||
import { PageTitleProvider } from '../../modules/page-title/PageTitleContext';
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<SidebarProvider>
|
||||
<PageTitleProvider>{children}</PageTitleProvider>
|
||||
</SidebarProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
62
src/app/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AuthContext } from '../../modules/auth/AuthContext';
|
||||
import { getToken, setToken as saveToken, removeToken } from '../../modules/auth/auth.storage';
|
||||
import { authService } from '../../modules/auth/auth.service';
|
||||
import type { User } from '../../types/auth.types';
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setTokenState] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const isAuthenticated = !!token && !!user;
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = getToken();
|
||||
if (!storedToken) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTokenState(storedToken);
|
||||
|
||||
authService
|
||||
.getMe()
|
||||
.then((userData) => {
|
||||
setUser(userData);
|
||||
})
|
||||
.catch(() => {
|
||||
removeToken();
|
||||
setTokenState(null);
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const response = await authService.login({ email, password });
|
||||
saveToken(response.accessToken);
|
||||
setTokenState(response.accessToken);
|
||||
setUser(response.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
removeToken();
|
||||
setTokenState(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((data: Partial<User>) => {
|
||||
setUser((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, token, isAuthenticated, isLoading, login, logout, updateUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
15
src/app/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
75
src/app/providers/SidebarProvider.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SidebarContext } from '../../modules/sidebar/SidebarContext';
|
||||
|
||||
const STORAGE_KEY = '@iasis:sidebar-expanded';
|
||||
const MOBILE_QUERY = '(max-width: 768px)';
|
||||
|
||||
function readStoredExpanded(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? true : stored === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isExpanded, setIsExpanded] = useState(readStoredExpanded);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
|
||||
const location = useLocation();
|
||||
|
||||
// Detect mobile breakpoint changes
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setIsMobile(e.matches);
|
||||
if (e.matches) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Close mobile sidebar on navigation
|
||||
useEffect(() => {
|
||||
setIsMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Persist desktop expanded preference
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(isExpanded));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const openMobile = useCallback(() => {
|
||||
setIsMobileOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMobile = useCallback(() => {
|
||||
setIsMobileOpen(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isExpanded,
|
||||
isMobile,
|
||||
isMobileOpen,
|
||||
toggleSidebar,
|
||||
openMobile,
|
||||
closeMobile,
|
||||
}),
|
||||
[isExpanded, isMobile, isMobileOpen, toggleSidebar, openMobile, closeMobile],
|
||||
);
|
||||
|
||||
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||
}
|
||||
42
src/app/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ThemeContext, type Theme } from '../../modules/theme/ThemeContext';
|
||||
|
||||
const STORAGE_KEY = '@iasis:theme';
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyThemeToDocument(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeToDocument(theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
2
src/app/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppProviders } from './AppProviders';
|
||||
export { AuthProvider } from './AuthProvider';
|
||||
4
src/app/router/AppRouter.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { routes } from './routes';
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
19
src/app/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
|
||||
export function AuthGuard() {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (user?.mustChangePassword && location.pathname !== '/trocar-senha') {
|
||||
return <Navigate to="/trocar-senha" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
14
src/app/router/GuestGuard.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
|
||||
export function GuestGuard() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
19
src/app/router/RoleGuard.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
import type { UserRole } from '../../types/auth.types';
|
||||
|
||||
interface RoleGuardProps {
|
||||
allowedRoles: UserRole[];
|
||||
}
|
||||
|
||||
export function RoleGuard({ allowedRoles }: RoleGuardProps) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
10
src/app/router/RootLayout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { AppProviders } from '../providers';
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<Outlet />
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
98
src/app/router/__tests__/AuthGuard.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AuthGuard } from '../AuthGuard';
|
||||
import type { User } from '../../../types/auth.types';
|
||||
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
email: 'test@test.com',
|
||||
role: 'ADMIN',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
vi.mock('../../../modules/auth', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const { useAuth } = await import('../../../modules/auth');
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
|
||||
function setAuth(overrides: Partial<ReturnType<typeof useAuth>> = {}) {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
...overrides,
|
||||
} as ReturnType<typeof useAuth>);
|
||||
}
|
||||
|
||||
function renderWithRoutes(initialPath: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/trocar-senha" element={<div>Change Password</div>} />
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route path="/" element={<div>Dashboard</div>} />
|
||||
<Route path="/dashboard" element={<div>Dashboard</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('redirects to /login when not authenticated', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithRoutes('/dashboard');
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Outlet normally when authenticated and mustChangePassword=false', () => {
|
||||
setAuth({ user: { ...mockUser, mustChangePassword: false } });
|
||||
|
||||
renderWithRoutes('/');
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /trocar-senha when mustChangePassword=true', () => {
|
||||
setAuth({ user: { ...mockUser, mustChangePassword: true } });
|
||||
|
||||
renderWithRoutes('/dashboard');
|
||||
|
||||
expect(screen.getByText('Change Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null while loading', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = renderWithRoutes('/');
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
78
src/app/router/__tests__/RoleGuard.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RoleGuard } from '../RoleGuard';
|
||||
import type { UserRole } from '../../../types/auth.types';
|
||||
|
||||
const mockUser = { id: '1', name: 'Test', email: 'test@test.com', role: 'ADMIN' as UserRole };
|
||||
|
||||
vi.mock('../../../modules/auth', () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { useAuth } = await import('../../../modules/auth');
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
|
||||
function setRole(role: UserRole) {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { ...mockUser, role },
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithRoute(allowedRoles: UserRole[], initialPath: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<div>Dashboard</div>} />
|
||||
<Route element={<RoleGuard allowedRoles={allowedRoles} />}>
|
||||
<Route path="/usuarios" element={<div>Users Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setRole('ADMIN');
|
||||
});
|
||||
|
||||
describe('RoleGuard', () => {
|
||||
it('renders children when user has allowed role', () => {
|
||||
setRole('ADMIN');
|
||||
renderWithRoute(['ADMIN'], '/usuarios');
|
||||
expect(screen.getByText('Users Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to / when user role is not allowed', () => {
|
||||
setRole('GESTOR_PROJETOS');
|
||||
renderWithRoute(['ADMIN'], '/usuarios');
|
||||
expect(screen.queryByText('Users Page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects PO from admin+gestor_projetos routes', () => {
|
||||
setRole('PO');
|
||||
renderWithRoute(['ADMIN', 'GESTOR_PROJETOS'], '/usuarios');
|
||||
expect(screen.queryByText('Users Page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows GESTOR_PROJETOS on admin+gestor_projetos routes', () => {
|
||||
setRole('GESTOR_PROJETOS');
|
||||
renderWithRoute(['ADMIN', 'GESTOR_PROJETOS'], '/usuarios');
|
||||
expect(screen.getByText('Users Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
src/app/router/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { router } from './AppRouter';
|
||||
142
src/app/router/routes.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { AppLayout } from '../../components/layout';
|
||||
import { DashboardPage } from '../../pages/dashboard/DashboardPage';
|
||||
import { LoginPage } from '../../pages/auth/LoginPage';
|
||||
import { EntregaveisListPage } from '../../pages/entregaveis/EntregaveisListPage';
|
||||
import { EntregavelCreatePage } from '../../pages/entregaveis/EntregavelCreatePage';
|
||||
import { EntregavelDetailPage } from '../../pages/entregaveis/EntregavelDetailPage';
|
||||
import { EntregavelEditPage } from '../../pages/entregaveis/EntregavelEditPage';
|
||||
import { OrdensServicoListPage } from '../../pages/ordens-servico/OrdensServicoListPage';
|
||||
import { OrdemServicoCreatePage } from '../../pages/ordens-servico/OrdemServicoCreatePage';
|
||||
import { OrdemServicoEditPage } from '../../pages/ordens-servico/OrdemServicoEditPage';
|
||||
import { OrdemServicoDetailPage } from '../../pages/ordens-servico/OrdemServicoDetailPage';
|
||||
import { SprintsPage } from '../../pages/sprints/SprintsPage';
|
||||
import { SprintCreatePage } from '../../pages/sprints/SprintCreatePage';
|
||||
import { SprintDetailPage } from '../../pages/sprints/SprintDetailPage';
|
||||
import { SprintEditPage } from '../../pages/sprints/SprintEditPage';
|
||||
import { ProfessionalsPage } from '../../pages/professionals/ProfessionalsPage';
|
||||
import { ProfessionalCreatePage } from '../../pages/professionals/ProfessionalCreatePage';
|
||||
import { ProfessionalEditPage } from '../../pages/professionals/ProfessionalEditPage';
|
||||
import { UsersPage } from '../../pages/users/UsersPage';
|
||||
import { UserCreatePage } from '../../pages/users/UserCreatePage';
|
||||
import { UserEditPage } from '../../pages/users/UserEditPage';
|
||||
import { ClientsPage } from '../../pages/clients/ClientsPage';
|
||||
import { ClientCreatePage } from '../../pages/clients/ClientCreatePage';
|
||||
import { ClientEditPage } from '../../pages/clients/ClientEditPage';
|
||||
import { ClientDetailPage } from '../../pages/clients/ClientDetailPage';
|
||||
import { ClientContractCreatePage } from '../../pages/clients/ClientContractCreatePage';
|
||||
import { ClientContractItemCreatePage } from '../../pages/clients/ClientContractItemCreatePage';
|
||||
import { ClientContractItemEditPage } from '../../pages/clients/ClientContractItemEditPage';
|
||||
import { ClientProjectCreatePage } from '../../pages/clients/ClientProjectCreatePage';
|
||||
import { ContractEditPage } from '../../pages/contracts/ContractEditPage';
|
||||
import { ProjectEditPage } from '../../pages/projects/ProjectEditPage';
|
||||
import { SettingsPage } from '../../pages/settings/SettingsPage';
|
||||
import { ChangePasswordPage } from '../../pages/auth/ChangePasswordPage';
|
||||
import { ForgotPasswordPage } from '../../pages/auth/ForgotPasswordPage';
|
||||
import { ResetPasswordPage } from '../../pages/auth/ResetPasswordPage';
|
||||
import { ApiKeysPage } from '../../pages/admin/api-keys';
|
||||
import { PoPage } from '../../pages/po/PoPage';
|
||||
import { FiscalContratoPage } from '../../pages/fiscal-contrato/FiscalContratoPage';
|
||||
import { GestorContratoPage } from '../../pages/gestor-contrato/GestorContratoPage';
|
||||
import { AuthGuard } from './AuthGuard';
|
||||
import { GuestGuard } from './GuestGuard';
|
||||
import { RoleGuard } from './RoleGuard';
|
||||
import { RootLayout } from './RootLayout';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <GuestGuard />,
|
||||
children: [{ path: '/login', element: <LoginPage /> }],
|
||||
},
|
||||
{ path: '/esqueci-senha', element: <ForgotPasswordPage /> },
|
||||
{ path: '/redefinir-senha/:token', element: <ResetPasswordPage /> },
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: 'trocar-senha', element: <ChangePasswordPage /> },
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
// Acessivel a todos os roles
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
// Todos os perfis (visualização)
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['ADMIN', 'GESTOR_PROJETOS', 'PO', 'FISCAL_CONTRATO', 'GESTOR_CONTRATO']} />,
|
||||
children: [
|
||||
{ path: 'entregaveis', element: <EntregaveisListPage /> },
|
||||
{ path: 'entregaveis/:id', element: <EntregavelDetailPage /> },
|
||||
{ path: 'ordens-servico', element: <OrdensServicoListPage /> },
|
||||
{ path: 'ordens-servico/:id', element: <OrdemServicoDetailPage /> },
|
||||
{ path: 'sprints', element: <SprintsPage /> },
|
||||
{ path: 'sprints/:id', element: <SprintDetailPage /> },
|
||||
{ path: 'clientes', element: <ClientsPage /> },
|
||||
{ path: 'clientes/:id', element: <ClientDetailPage /> },
|
||||
],
|
||||
},
|
||||
// ADMIN + GESTOR_PROJETOS (criação e edição)
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['ADMIN', 'GESTOR_PROJETOS']} />,
|
||||
children: [
|
||||
{ path: 'entregaveis/novo', element: <EntregavelCreatePage /> },
|
||||
{ path: 'entregaveis/:id/editar', element: <EntregavelEditPage /> },
|
||||
{ path: 'ordens-servico/nova', element: <OrdemServicoCreatePage /> },
|
||||
{ path: 'ordens-servico/:id/editar', element: <OrdemServicoEditPage /> },
|
||||
{ path: 'sprints/novo', element: <SprintCreatePage /> },
|
||||
{ path: 'sprints/:id/editar', element: <SprintEditPage /> },
|
||||
{ path: 'profissionais', element: <ProfessionalsPage /> },
|
||||
{ path: 'profissionais/novo', element: <ProfessionalCreatePage /> },
|
||||
{ path: 'profissionais/:id/editar', element: <ProfessionalEditPage /> },
|
||||
{ path: 'clientes/novo', element: <ClientCreatePage /> },
|
||||
{ path: 'clientes/:id/editar', element: <ClientEditPage /> },
|
||||
{ path: 'clientes/:id/contratos/novo', element: <ClientContractCreatePage /> },
|
||||
{
|
||||
path: 'clientes/:id/itens-contrato/novo',
|
||||
element: <ClientContractItemCreatePage />,
|
||||
},
|
||||
{
|
||||
path: 'clientes/:id/itens-contrato/:itemId/editar',
|
||||
element: <ClientContractItemEditPage />,
|
||||
},
|
||||
{ path: 'clientes/:id/projetos/novo', element: <ClientProjectCreatePage /> },
|
||||
{ path: 'contratos/:id/editar', element: <ContractEditPage /> },
|
||||
{ path: 'projetos/:id/editar', element: <ProjectEditPage /> },
|
||||
],
|
||||
},
|
||||
// PO only
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['PO']} />,
|
||||
children: [{ path: 'po', element: <PoPage /> }],
|
||||
},
|
||||
// FISCAL_CONTRATO only
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['FISCAL_CONTRATO']} />,
|
||||
children: [{ path: 'fiscal-contrato', element: <FiscalContratoPage /> }],
|
||||
},
|
||||
// GESTOR_CONTRATO only
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['GESTOR_CONTRATO']} />,
|
||||
children: [{ path: 'gestor-contrato', element: <GestorContratoPage /> }],
|
||||
},
|
||||
// ADMIN only
|
||||
{
|
||||
element: <RoleGuard allowedRoles={['ADMIN']} />,
|
||||
children: [
|
||||
{ path: 'usuarios', element: <UsersPage /> },
|
||||
{ path: 'usuarios/novo', element: <UserCreatePage /> },
|
||||
{ path: 'usuarios/:id/editar', element: <UserEditPage /> },
|
||||
{ path: 'configuracoes', element: <SettingsPage /> },
|
||||
{ path: 'admin/integracoes/api-keys', element: <ApiKeysPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
BIN
src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
25
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { AppTopbar } from './AppTopbar';
|
||||
import { useSidebar } from '../../hooks/useSidebar';
|
||||
|
||||
export function AppLayout() {
|
||||
const { isExpanded } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg">
|
||||
<AppSidebar />
|
||||
<AppTopbar />
|
||||
|
||||
<main
|
||||
className={[
|
||||
'mt-14 min-h-[calc(100vh-3.5rem)] transition-all duration-300 ease-in-out',
|
||||
'max-md:ml-0',
|
||||
isExpanded ? 'md:ml-60' : 'md:ml-16',
|
||||
].join(' ')}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { NavItem } from './NavItem';
|
||||
import { navigationItems } from '../../constants/navigation';
|
||||
import { useSidebar } from '../../hooks/useSidebar';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { isExpanded, isMobile, isMobileOpen, closeMobile, toggleSidebar } = useSidebar();
|
||||
const { canAccess } = usePermission();
|
||||
|
||||
const visibleItems = navigationItems.filter((item) => canAccess(item.to));
|
||||
|
||||
// Mobile sidebar always shows full content (icon + label);
|
||||
// collapse only applies to desktop.
|
||||
const collapsed = isMobile ? false : !isExpanded;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
data-testid="sidebar-backdrop"
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={closeMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
data-testid="app-sidebar"
|
||||
className={[
|
||||
'fixed left-0 top-0 h-screen bg-card flex flex-col border-r transition-all duration-300 ease-in-out',
|
||||
// Mobile: overlay mode, always w-60
|
||||
'max-md:z-40 max-md:w-60',
|
||||
isMobileOpen ? 'max-md:translate-x-0' : 'max-md:-translate-x-full',
|
||||
// Desktop: expanded or collapsed
|
||||
isExpanded ? 'md:w-60' : 'md:w-16',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={`h-14 flex items-center shrink-0 ${collapsed ? 'justify-center px-2' : 'px-5'}`}
|
||||
>
|
||||
<img
|
||||
src={collapsed ? '/logo/logo-icon.png' : '/logo/logo-full.png'}
|
||||
alt="ISIS"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-2 flex flex-col gap-1">
|
||||
{visibleItems.map((item) => (
|
||||
<NavItem
|
||||
key={item.to}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
to={item.to}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Toggle button (desktop only) */}
|
||||
<button
|
||||
data-testid="sidebar-toggle"
|
||||
onClick={toggleSidebar}
|
||||
className="hidden md:flex items-center justify-center h-10 mx-2 mb-2 rounded-md text-muted hover:bg-hover hover:text-secondary transition-colors duration-150"
|
||||
>
|
||||
{isExpanded ? <ChevronsLeft size={18} /> : <ChevronsRight size={18} />}
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/components/layout/AppTopbar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, LogOut } from 'lucide-react';
|
||||
import { ThemeSwitcher } from '../ui/ThemeSwitcher';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { useSidebar } from '../../hooks/useSidebar';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
ADMIN: 'Administrador',
|
||||
OPERATOR: 'Operador',
|
||||
CLIENT: 'Cliente',
|
||||
};
|
||||
|
||||
export function AppTopbar() {
|
||||
const { isExpanded, openMobile } = useSidebar();
|
||||
const { title, subtitle } = usePageTitle();
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
}, [logout, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
if (menuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [menuOpen]);
|
||||
|
||||
const userInitial = user?.name?.charAt(0).toUpperCase() || 'U';
|
||||
|
||||
return (
|
||||
<header
|
||||
className={[
|
||||
'fixed top-0 right-0 h-14 bg-card flex items-center justify-between px-6 border-b z-10 transition-all duration-300 ease-in-out',
|
||||
// Mobile: full width
|
||||
'max-md:left-0',
|
||||
// Desktop: offset by sidebar width
|
||||
isExpanded ? 'md:left-60' : 'md:left-16',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
data-testid="mobile-menu-button"
|
||||
onClick={openMobile}
|
||||
className="md:hidden p-1 mr-2 text-muted hover:text-secondary"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-base font-semibold text-text-primary">{title}</h1>
|
||||
{subtitle && <span className="text-small text-text-muted">{subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSwitcher />
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
data-testid="user-menu-button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="w-8 h-8 rounded-full bg-hover flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<span className="text-small text-secondary">{userInitial}</span>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-10 w-48 rounded-lg border border-border-strong bg-card shadow-lg py-2">
|
||||
<div className="px-4 py-2 border-b border-border-strong">
|
||||
<p className="text-small font-medium text-text-primary truncate">{user?.name}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{roleLabels[user?.role ?? ''] ?? user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="logout-button"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-small text-text-secondary hover:bg-hover transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
17
src/components/layout/FormCard.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FormCardProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormCard({ title, children }: FormCardProps) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[660px] mt-4">
|
||||
<div className="rounded-2xl border border-border/50 bg-card p-6 sm:p-8 shadow-sm">
|
||||
<h2 className="text-h2 text-text-primary mb-6">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/layout/NavItem.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
interface NavItemProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
to: string;
|
||||
active?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function NavItem({ icon: Icon, label, to, active, collapsed = false }: NavItemProps) {
|
||||
const location = useLocation();
|
||||
const isActive = active ?? location.pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center rounded-md text-body transition-colors duration-150 ${
|
||||
collapsed ? 'justify-center px-2 py-2' : 'gap-3 px-3 py-2'
|
||||
} ${isActive ? 'bg-hover text-isis-blue' : 'text-muted hover:bg-hover hover:text-secondary'}`}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
14
src/components/layout/PageContainer.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageContainer({ children, className }: PageContainerProps) {
|
||||
return (
|
||||
<div className={`px-6 py-5 w-full max-w-full${className ? ` ${className}` : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Breadcrumbs, type BreadcrumbItem } from '../ui';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions, breadcrumbs }: PageHeaderProps) {
|
||||
const { setPageTitle } = usePageTitle();
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle(title, subtitle);
|
||||
}, [title, subtitle, setPageTitle]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>{breadcrumbs && breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}</div>
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/layout/__tests__/PageHeader.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PageHeader } from '../PageHeader';
|
||||
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<PageTitleProvider>{ui}</PageTitleProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PageHeader', () => {
|
||||
it('renderiza breadcrumbs quando prop fornecida', () => {
|
||||
renderWithRouter(
|
||||
<PageHeader
|
||||
title="Página"
|
||||
breadcrumbs={[{ label: 'Início', to: '/' }, { label: 'Página' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Início')).toBeInTheDocument();
|
||||
expect(screen.getByText('Página')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('não renderiza breadcrumbs quando prop ausente', () => {
|
||||
renderWithRouter(<PageHeader title="Página" />);
|
||||
|
||||
expect(screen.queryByRole('navigation', { name: 'Breadcrumb' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('não renderiza breadcrumbs quando array vazio', () => {
|
||||
renderWithRouter(<PageHeader title="Página" breadcrumbs={[]} />);
|
||||
|
||||
expect(screen.queryByRole('navigation', { name: 'Breadcrumb' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renderiza ações com breadcrumbs', () => {
|
||||
renderWithRouter(
|
||||
<PageHeader
|
||||
title="Meu Título"
|
||||
subtitle="Meu subtítulo"
|
||||
actions={<button>Ação</button>}
|
||||
breadcrumbs={[{ label: 'Início', to: '/' }, { label: 'Atual' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Ação')).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renderiza ações sem breadcrumbs', () => {
|
||||
renderWithRouter(
|
||||
<PageHeader title="Meu Título" subtitle="Meu subtítulo" actions={<button>Ação</button>} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Ação')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('navigation', { name: 'Breadcrumb' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
184
src/components/layout/__tests__/Sidebar.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { SidebarProvider } from '../../../app/providers/SidebarProvider';
|
||||
import { AppSidebar } from '../AppSidebar';
|
||||
import { AppTopbar } from '../AppTopbar';
|
||||
import { AppLayout } from '../AppLayout';
|
||||
import { ThemeProvider } from '../../../app/providers/ThemeProvider';
|
||||
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
|
||||
|
||||
vi.mock('../../../modules/auth', () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
user: { id: '1', name: 'Admin', email: 'admin@test.com', role: 'ADMIN' },
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode, { mobile = false } = {}) {
|
||||
mockMatchMedia(mobile);
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>
|
||||
<PageTitleProvider>{ui}</PageTitleProvider>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
it('renders expanded by default with labels', () => {
|
||||
renderWithProviders(<AppSidebar />);
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('md:w-60');
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('ISIS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses on toggle click hiding labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AppSidebar />);
|
||||
|
||||
const toggle = screen.getByTestId('sidebar-toggle');
|
||||
await user.click(toggle);
|
||||
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('md:w-16');
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands again on second toggle click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AppSidebar />);
|
||||
|
||||
const toggle = screen.getByTestId('sidebar-toggle');
|
||||
await user.click(toggle);
|
||||
await user.click(toggle);
|
||||
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('md:w-60');
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists collapsed preference in localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AppSidebar />);
|
||||
|
||||
await user.click(screen.getByTestId('sidebar-toggle'));
|
||||
expect(localStorage.getItem('@iasis:sidebar-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('shows tooltip on collapsed NavItem', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AppSidebar />);
|
||||
|
||||
await user.click(screen.getByTestId('sidebar-toggle'));
|
||||
|
||||
const dashboardLink = screen.getByTitle('Dashboard');
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppSidebar — mobile', () => {
|
||||
it('is hidden by default on mobile', () => {
|
||||
renderWithProviders(<AppSidebar />, { mobile: true });
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('-translate-x-full');
|
||||
});
|
||||
|
||||
it('shows backdrop and sidebar when mobile open', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
</>,
|
||||
{ mobile: true },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('mobile-menu-button'));
|
||||
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('translate-x-0');
|
||||
expect(screen.getByTestId('sidebar-backdrop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes sidebar when clicking backdrop', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
</>,
|
||||
{ mobile: true },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('mobile-menu-button'));
|
||||
await user.click(screen.getByTestId('sidebar-backdrop'));
|
||||
|
||||
const sidebar = screen.getByTestId('app-sidebar');
|
||||
expect(sidebar.className).toContain('-translate-x-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppTopbar', () => {
|
||||
it('has left-60 offset when sidebar is expanded on desktop', () => {
|
||||
renderWithProviders(<AppTopbar />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header.className).toContain('md:left-60');
|
||||
});
|
||||
|
||||
it('has left-16 offset when sidebar is collapsed on desktop', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<AppSidebar />
|
||||
<AppTopbar />
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('sidebar-toggle'));
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header.className).toContain('md:left-16');
|
||||
});
|
||||
|
||||
it('shows hamburger button on mobile', () => {
|
||||
renderWithProviders(<AppTopbar />, { mobile: true });
|
||||
expect(screen.getByTestId('mobile-menu-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppLayout', () => {
|
||||
it('has ml-60 when sidebar expanded', () => {
|
||||
renderWithProviders(<AppLayout />);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.className).toContain('md:ml-60');
|
||||
});
|
||||
|
||||
it('has ml-16 when sidebar collapsed', async () => {
|
||||
localStorage.setItem('@iasis:sidebar-expanded', 'false');
|
||||
renderWithProviders(<AppLayout />);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.className).toContain('md:ml-16');
|
||||
});
|
||||
});
|
||||
102
src/components/layout/__tests__/SidebarRoles.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SidebarProvider } from '../../../app/providers/SidebarProvider';
|
||||
import { ThemeProvider } from '../../../app/providers/ThemeProvider';
|
||||
import { AppSidebar } from '../AppSidebar';
|
||||
import type { UserRole } from '../../../types/auth.types';
|
||||
|
||||
const mockUser = { id: '1', name: 'Test', email: 'test@test.com', role: 'ADMIN' as UserRole };
|
||||
|
||||
vi.mock('../../../modules/auth', () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { useAuth } = await import('../../../modules/auth');
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
|
||||
function setRole(role: UserRole) {
|
||||
mockUseAuth.mockReturnValue({
|
||||
user: { ...mockUser, role },
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function mockMatchMedia() {
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
mockMatchMedia();
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setRole('ADMIN');
|
||||
});
|
||||
|
||||
describe('AppSidebar — role-based navigation', () => {
|
||||
it('ADMIN sees all menu items', () => {
|
||||
setRole('ADMIN');
|
||||
renderSidebar();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ordens de Serviço')).toBeInTheDocument();
|
||||
expect(screen.getByText('Entregáveis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clientes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sprints')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profissionais')).toBeInTheDocument();
|
||||
expect(screen.getByText('Usuários')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Keys')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('GESTOR_PROJETOS does not see Usuários or API Keys', () => {
|
||||
setRole('GESTOR_PROJETOS');
|
||||
renderSidebar();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ordens de Serviço')).toBeInTheDocument();
|
||||
expect(screen.getByText('Entregáveis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sprints')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profissionais')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clientes')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Usuários')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('API Keys')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PO sees only its allowed routes', () => {
|
||||
setRole('PO');
|
||||
renderSidebar();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Entregáveis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ordens de Serviço')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clientes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sprints')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Profissionais')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Usuários')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('API Keys')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
7
src/components/layout/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { FormCard } from './FormCard';
|
||||
export { PageContainer } from './PageContainer';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export { NavItem } from './NavItem';
|
||||
export { AppLayout } from './AppLayout';
|
||||
export { AppSidebar } from './AppSidebar';
|
||||
export { AppTopbar } from './AppTopbar';
|
||||
0
src/components/shared/.gitkeep
Normal file
62
src/components/shared/PasswordStrengthIndicator.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
interface Rule {
|
||||
label: string;
|
||||
test: (password: string) => boolean;
|
||||
}
|
||||
|
||||
const rules: Rule[] = [
|
||||
{ label: 'Mínimo 8 caracteres', test: (p) => p.length >= 8 },
|
||||
{ label: 'Letra maiúscula', test: (p) => /[A-Z]/.test(p) },
|
||||
{ label: 'Letra minúscula', test: (p) => /[a-z]/.test(p) },
|
||||
{ label: 'Número', test: (p) => /[0-9]/.test(p) },
|
||||
];
|
||||
|
||||
interface PasswordStrengthIndicatorProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) {
|
||||
const passed = rules.filter((r) => r.test(password)).length;
|
||||
const total = rules.length;
|
||||
const strengthPercent = (passed / total) * 100;
|
||||
|
||||
const strengthColor =
|
||||
passed === 0
|
||||
? 'bg-white/10'
|
||||
: passed < 2
|
||||
? 'bg-red-500'
|
||||
: passed < total
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500';
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="h-1 w-full overflow-hidden rounded bg-white/10">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${strengthColor}`}
|
||||
style={{ width: `${strengthPercent}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={passed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-label="Força da senha"
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{rules.map((rule) => {
|
||||
const ok = rule.test(password);
|
||||
return (
|
||||
<li key={rule.label} className="flex items-center gap-1.5 text-small">
|
||||
<span
|
||||
className={ok ? 'text-green-400' : 'text-white/30'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ok ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={ok ? 'text-text-secondary' : 'text-white/40'}>{rule.label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/shared/StatusBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { getStatusConfig } from '../../constants/deliverable-status';
|
||||
import type { DeliverableStatus } from '../../types/deliverable.types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: DeliverableStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, size }: StatusBadgeProps) {
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} size={size}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
68
src/components/shared/StatusHistoryList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useStatusHistory } from '../../hooks/useDeliverables';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface StatusHistoryListProps {
|
||||
serviceOrderId: string;
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function StatusHistoryList({ serviceOrderId }: StatusHistoryListProps) {
|
||||
const { data: history = [], isLoading } = useStatusHistory(serviceOrderId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-hover" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return <p className="text-body text-text-muted">Nenhuma mudança de status registrada.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{history.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.previousStatus && (
|
||||
<>
|
||||
<StatusBadge status={item.previousStatus} size="sm" />
|
||||
<ArrowRight className="h-3.5 w-3.5 text-text-muted" />
|
||||
</>
|
||||
)}
|
||||
<StatusBadge status={item.newStatus} size="sm" />
|
||||
</div>
|
||||
|
||||
{item.observation && (
|
||||
<p className="mt-2 text-small text-text-secondary">{item.observation}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-text-muted">
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{item.createdBy && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{item.createdBy}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/shared/StatusTransitionModal.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { DeliverableStatus } from '../../types/deliverable.types';
|
||||
import { useAllowedTransitions, useChangeStatus } from '../../hooks/useDeliverables';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { Button } from '../ui/Button';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface StatusTransitionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceOrderId: string;
|
||||
currentStatus: DeliverableStatus;
|
||||
sprintLinked?: boolean;
|
||||
workOrderId?: string | null;
|
||||
}
|
||||
|
||||
const OBSERVATION_REQUIRED_TARGETS = new Set<DeliverableStatus>([
|
||||
DeliverableStatus.CANCELADA,
|
||||
DeliverableStatus.GLOSADA,
|
||||
]);
|
||||
|
||||
function isObservationRequired(from: DeliverableStatus, to: DeliverableStatus): boolean {
|
||||
if (OBSERVATION_REQUIRED_TARGETS.has(to)) return true;
|
||||
if (from === DeliverableStatus.EM_REVISAO && to === DeliverableStatus.AGUARDANDO_VALIDACAO) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function StatusTransitionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceOrderId,
|
||||
currentStatus,
|
||||
sprintLinked = true,
|
||||
workOrderId,
|
||||
}: StatusTransitionModalProps) {
|
||||
const [selectedStatus, setSelectedStatus] = useState<DeliverableStatus | null>(null);
|
||||
const [observation, setObservation] = useState('');
|
||||
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
||||
|
||||
const { data: allowedTransitions = [], isLoading: isLoadingTransitions } =
|
||||
useAllowedTransitions(serviceOrderId);
|
||||
const changeStatus = useChangeStatus();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const observationRequired =
|
||||
selectedStatus !== null && isObservationRequired(currentStatus, selectedStatus);
|
||||
|
||||
const canSubmit =
|
||||
selectedStatus !== null &&
|
||||
(!observationRequired || observation.trim().length > 0) &&
|
||||
!changeStatus.isPending;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (changeStatus.isPending) return;
|
||||
setSelectedStatus(null);
|
||||
setObservation('');
|
||||
onClose();
|
||||
}, [onClose, changeStatus.isPending]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedStatus(null);
|
||||
setObservation('');
|
||||
setSubmissionError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedStatus || !canSubmit) return;
|
||||
setSubmissionError(null);
|
||||
|
||||
changeStatus.mutate(
|
||||
{
|
||||
id: serviceOrderId,
|
||||
data: {
|
||||
status: selectedStatus,
|
||||
observation: observation.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.lowBalanceWarning) {
|
||||
showToast('Atenção: o saldo da OS Mãe está abaixo de 20% após esta emissão', 'warning');
|
||||
} else {
|
||||
showToast('Status atualizado com sucesso', 'success');
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = axios.isAxiosError(err)
|
||||
? (err.response?.data?.message as string | undefined)
|
||||
: undefined;
|
||||
if (message?.includes('Saldo insuficiente no pool')) {
|
||||
setSubmissionError(message);
|
||||
} else {
|
||||
showToast(message ?? 'Erro ao atualizar status', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border bg-card p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-1 text-lg font-semibold text-primary">Alterar Status</h2>
|
||||
<p className="mb-4 text-small text-text-secondary">
|
||||
Status atual: <StatusBadge status={currentStatus} size="sm" />
|
||||
</p>
|
||||
|
||||
{isLoadingTransitions ? (
|
||||
<p className="text-body text-text-secondary">Carregando...</p>
|
||||
) : allowedTransitions.length === 0 ? (
|
||||
<p className="text-body text-text-secondary">
|
||||
Não há transições disponíveis para este status.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-small font-medium text-primary">Novo status</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allowedTransitions.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setSelectedStatus(status)}
|
||||
className={`rounded-lg border px-3 py-2 transition-colors ${
|
||||
selectedStatus === status
|
||||
? 'border-isis-blue bg-isis-blue/10'
|
||||
: 'border-border hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStatus === DeliverableStatus.RASCUNHO && !sprintLinked && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning" />
|
||||
<p className="text-small text-text-secondary">
|
||||
Para emitir este entregável, vincule-o a uma sprint na tela de edição.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-small font-medium text-primary">
|
||||
Observação
|
||||
{observationRequired && <span className="ml-1 text-danger">*</span>}
|
||||
</label>
|
||||
{observationRequired && (
|
||||
<p className="mb-1 text-[11px] text-danger">
|
||||
Observação obrigatória para esta transição
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={observation}
|
||||
onChange={(e) => setObservation(e.target.value)}
|
||||
placeholder="Informe o motivo da mudança de status..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{submissionError && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/30 bg-danger/5 p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-danger" />
|
||||
<div className="text-small text-text-secondary">
|
||||
<p>{submissionError}</p>
|
||||
{workOrderId && (
|
||||
<Link
|
||||
to={`/ordens-servico/${workOrderId}`}
|
||||
className="mt-1 inline-block text-isis-blue hover:underline"
|
||||
>
|
||||
Verificar pool da OS Mãe →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={handleClose} disabled={changeStatus.isPending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={changeStatus.isPending}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{changeStatus.isPending ? 'Aguarde...' : 'Confirmar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
26
src/components/shared/__tests__/StatusBadge.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StatusBadge } from '../StatusBadge';
|
||||
import { DeliverableStatus } from '../../../types/deliverable.types';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it.each([
|
||||
[DeliverableStatus.RASCUNHO, 'Rascunho', 'text-secondary'],
|
||||
[DeliverableStatus.EMITIDA, 'Emitida', 'text-info'],
|
||||
[DeliverableStatus.EM_EXECUCAO, 'Em Execução', 'text-warning'],
|
||||
[DeliverableStatus.AGUARDANDO_VALIDACAO, 'Aguardando Validação', 'text-warning'],
|
||||
[DeliverableStatus.EM_REVISAO, 'Em Revisão', 'text-purple-600'],
|
||||
[DeliverableStatus.APROVADA, 'Aprovada', 'text-success'],
|
||||
[DeliverableStatus.GLOSADA, 'Glosada', 'text-danger'],
|
||||
[DeliverableStatus.ENCERRADA, 'Encerrada', 'text-emerald-700'],
|
||||
[DeliverableStatus.CANCELADA, 'Cancelada', 'text-red-800'],
|
||||
[DeliverableStatus.AGUARDANDO_PAGAMENTO, 'Aguardando Pagamento', 'text-warning'],
|
||||
[DeliverableStatus.PAGA, 'Paga', 'text-success'],
|
||||
] as const)('renderiza label "%s" como "%s" com cor correta', (status, label, colorClass) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
|
||||
const badge = screen.getByText(label);
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.className).toContain(colorClass);
|
||||
});
|
||||
});
|
||||
81
src/components/shared/__tests__/StatusHistoryList.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DeliverableStatus } from '../../../types/deliverable.types';
|
||||
import { StatusHistoryList } from '../StatusHistoryList';
|
||||
|
||||
const mockUseStatusHistory = vi.fn();
|
||||
|
||||
vi.mock('../../../hooks/useDeliverables', () => ({
|
||||
useStatusHistory: (...args: unknown[]) => mockUseStatusHistory(...args),
|
||||
}));
|
||||
|
||||
describe('StatusHistoryList', () => {
|
||||
it('exibe transições com badges corretos', () => {
|
||||
mockUseStatusHistory.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'h-1',
|
||||
serviceOrderId: 'os-1',
|
||||
previousStatus: DeliverableStatus.RASCUNHO,
|
||||
newStatus: DeliverableStatus.EMITIDA,
|
||||
observation: 'OS emitida para execução',
|
||||
createdAt: '2026-04-04T14:00:00Z',
|
||||
createdBy: 'admin-user',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<StatusHistoryList serviceOrderId="os-1" />);
|
||||
|
||||
expect(screen.getByText('Rascunho')).toBeInTheDocument();
|
||||
expect(screen.getByText('Emitida')).toBeInTheDocument();
|
||||
expect(screen.getByText('OS emitida para execução')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin-user')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exibe estado vazio quando não há histórico', () => {
|
||||
mockUseStatusHistory.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<StatusHistoryList serviceOrderId="os-1" />);
|
||||
|
||||
expect(screen.getByText('Nenhuma mudança de status registrada.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exibe loading state enquanto carrega', () => {
|
||||
mockUseStatusHistory.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const { container } = render(<StatusHistoryList serviceOrderId="os-1" />);
|
||||
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBe(3);
|
||||
});
|
||||
|
||||
it('exibe transição sem status anterior (criação)', () => {
|
||||
mockUseStatusHistory.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'h-1',
|
||||
serviceOrderId: 'os-1',
|
||||
previousStatus: null,
|
||||
newStatus: DeliverableStatus.RASCUNHO,
|
||||
observation: null,
|
||||
createdAt: '2026-04-04T10:00:00Z',
|
||||
createdBy: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<StatusHistoryList serviceOrderId="os-1" />);
|
||||
|
||||
expect(screen.getByText('Rascunho')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Nenhuma mudança de status registrada.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
154
src/components/shared/__tests__/StatusTransitionModal.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { DeliverableStatus } from '../../../types/deliverable.types';
|
||||
import { StatusTransitionModal } from '../StatusTransitionModal';
|
||||
|
||||
const mockShowToast = vi.fn();
|
||||
vi.mock('../../ui/Toast', () => ({
|
||||
useToast: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockAllowedTransitions: { data: DeliverableStatus[]; isLoading: boolean } = {
|
||||
data: [DeliverableStatus.EMITIDA, DeliverableStatus.CANCELADA],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
vi.mock('../../../hooks/useDeliverables', () => ({
|
||||
useAllowedTransitions: () => mockAllowedTransitions,
|
||||
useChangeStatus: () => ({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof StatusTransitionModal>> = {}) {
|
||||
const queryClient = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatusTransitionModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
serviceOrderId="os-id-1"
|
||||
currentStatus={DeliverableStatus.RASCUNHO}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StatusTransitionModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAllowedTransitions.data = [DeliverableStatus.EMITIDA, DeliverableStatus.CANCELADA];
|
||||
});
|
||||
|
||||
it('exibe apenas status permitidos', () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText('Emitida')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancelada')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Aprovada')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('campo de observação aparece como obrigatório para CANCELADA', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByText('Cancelada'));
|
||||
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
expect(screen.getByText('Observação obrigatória para esta transição')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('campo de observação não é obrigatório para EMITIDA', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByText('Emitida'));
|
||||
|
||||
expect(
|
||||
screen.queryByText('Observação obrigatória para esta transição'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('botão confirmar desabilitado sem seleção de status', () => {
|
||||
renderModal();
|
||||
|
||||
const confirmButton = screen.getByText('Confirmar');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('botão confirmar desabilitado quando observação obrigatória está vazia', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByText('Cancelada'));
|
||||
|
||||
const confirmButton = screen.getByText('Confirmar');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('botão confirmar habilitado quando observação obrigatória está preenchida', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByText('Cancelada'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Informe o motivo da mudança de status...'), {
|
||||
target: { value: 'Motivo do cancelamento' },
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Confirmar');
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('chama mutate ao confirmar com dados corretos', async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
|
||||
fireEvent.click(screen.getByText('Emitida'));
|
||||
fireEvent.click(screen.getByText('Confirmar'));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'os-id-1',
|
||||
data: {
|
||||
status: DeliverableStatus.EMITIDA,
|
||||
observation: undefined,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('não renderiza quando isOpen é false', () => {
|
||||
renderModal({ isOpen: false });
|
||||
|
||||
expect(screen.queryByText('Alterar Status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exibe aviso de sprint quando OS está em RASCUNHO sem sprint vinculada', () => {
|
||||
renderModal({ sprintLinked: false });
|
||||
|
||||
expect(
|
||||
screen.getByText('Para emitir este entregável, vincule-o a uma sprint na tela de edição.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('não exibe aviso de sprint quando OS está em RASCUNHO com sprint vinculada', () => {
|
||||
renderModal({ sprintLinked: true });
|
||||
|
||||
expect(
|
||||
screen.queryByText('Para emitir este entregável, vincule-o a uma sprint na tela de edição.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('não exibe aviso de sprint para status diferente de RASCUNHO', () => {
|
||||
mockAllowedTransitions.data = [DeliverableStatus.EM_EXECUCAO, DeliverableStatus.CANCELADA];
|
||||
renderModal({ currentStatus: DeliverableStatus.EMITIDA, sprintLinked: false });
|
||||
|
||||
expect(
|
||||
screen.queryByText('Para emitir este entregável, vincule-o a uma sprint na tela de edição.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
0
src/components/ui/.gitkeep
Normal file
47
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type BadgeVariant =
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'primary'
|
||||
| 'purple'
|
||||
| 'success-dark'
|
||||
| 'danger-dark'
|
||||
| 'neutral';
|
||||
type BadgeSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant;
|
||||
children: ReactNode;
|
||||
size?: BadgeSize;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
success: 'bg-success/20 text-success',
|
||||
danger: 'bg-danger/20 text-danger',
|
||||
warning: 'bg-warning/20 text-warning',
|
||||
info: 'bg-info/20 text-info',
|
||||
primary: 'bg-isis-blue/20 text-isis-blue',
|
||||
purple: 'bg-purple-500/20 text-purple-600 dark:text-purple-400',
|
||||
'success-dark': 'bg-emerald-700/20 text-emerald-700 dark:text-emerald-400',
|
||||
'danger-dark': 'bg-red-800/20 text-red-800 dark:text-red-400',
|
||||
neutral: 'bg-hover text-secondary',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<BadgeSize, string> = {
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-0.5 text-small',
|
||||
lg: 'px-2.5 py-1 text-small uppercase tracking-wide',
|
||||
};
|
||||
|
||||
export function Badge({ variant, children, size = 'md' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full font-medium ${variantClasses[variant]} ${sizeClasses[size]}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-1.5 list-none p-0 m-0">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={item.label} className="flex items-center gap-1.5">
|
||||
{index > 0 && <ChevronRight size={14} className="text-text-muted" />}
|
||||
{isLast || !item.to ? (
|
||||
<span className="text-small text-text-primary font-medium">{item.label}</span>
|
||||
) : (
|
||||
<Link
|
||||
to={item.to}
|
||||
className="text-small text-text-secondary hover:text-isis-blue transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
59
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-isis-blue text-white hover:bg-isis-blue-hover font-medium',
|
||||
secondary: 'border text-text-secondary hover:bg-hover',
|
||||
danger: 'bg-danger text-white hover:bg-danger/80 font-medium',
|
||||
ghost: 'text-isis-blue hover:text-isis-blue-hover hover:bg-hover',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-2 py-1 text-small gap-1',
|
||||
md: 'px-4 py-2 text-body gap-2',
|
||||
lg: 'px-6 py-3 text-body gap-2',
|
||||
icon: 'p-2',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center rounded transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
71
src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'default';
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirmar',
|
||||
cancelLabel = 'Cancelar',
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [open, handleKeyDown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border bg-card p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-2 text-lg font-semibold text-primary">{title}</h2>
|
||||
<p className="mb-6 text-body text-text-secondary">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === 'danger' ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Aguarde...' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
77
src/components/ui/CpfCnpjInput.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ChangeEvent } from 'react';
|
||||
import { isValidCpf } from './CpfInput';
|
||||
|
||||
interface CpfCnpjInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function maskCpfCnpj(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 14);
|
||||
if (digits.length <= 11) {
|
||||
return digits
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
||||
}
|
||||
return digits
|
||||
.replace(/(\d{2})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1/$2')
|
||||
.replace(/(\d{4})(\d{1,2})$/, '$1-$2');
|
||||
}
|
||||
|
||||
export function isValidCnpj(cnpj: string): boolean {
|
||||
const digits = cnpj.replace(/\D/g, '');
|
||||
if (digits.length !== 14) return false;
|
||||
if (/^(\d)\1{13}$/.test(digits)) return false;
|
||||
|
||||
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 12; i++) sum += parseInt(digits[i]) * weights1[i];
|
||||
let rest = sum % 11;
|
||||
const firstDigit = rest < 2 ? 0 : 11 - rest;
|
||||
if (parseInt(digits[12]) !== firstDigit) return false;
|
||||
|
||||
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
sum = 0;
|
||||
for (let i = 0; i < 13; i++) sum += parseInt(digits[i]) * weights2[i];
|
||||
rest = sum % 11;
|
||||
const secondDigit = rest < 2 ? 0 : 11 - rest;
|
||||
if (parseInt(digits[13]) !== secondDigit) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isValidCpfCnpj(value: string): boolean {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (digits.length <= 11) return isValidCpf(value);
|
||||
return isValidCnpj(value);
|
||||
}
|
||||
|
||||
export const CpfCnpjInput = forwardRef<HTMLInputElement, CpfCnpjInputProps>(
|
||||
({ label, error, onChange, className = '', ...rest }, ref) => {
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
e.target.value = maskCpfCnpj(e.target.value);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded border bg-bg px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none${error ? ' border-danger' : ''}`}
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={18}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CpfCnpjInput.displayName = 'CpfCnpjInput';
|
||||
61
src/components/ui/CpfInput.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ChangeEvent } from 'react';
|
||||
|
||||
interface CpfInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function maskCpf(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 11);
|
||||
return digits
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
||||
}
|
||||
|
||||
export function isValidCpf(cpf: string): boolean {
|
||||
const digits = cpf.replace(/\D/g, '');
|
||||
if (digits.length !== 11) return false;
|
||||
if (/^(\d)\1{10}$/.test(digits)) return false;
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 9; i++) sum += parseInt(digits[i]) * (10 - i);
|
||||
let rest = (sum * 10) % 11;
|
||||
if (rest === 10) rest = 0;
|
||||
if (rest !== parseInt(digits[9])) return false;
|
||||
|
||||
sum = 0;
|
||||
for (let i = 0; i < 10; i++) sum += parseInt(digits[i]) * (11 - i);
|
||||
rest = (sum * 10) % 11;
|
||||
if (rest === 10) rest = 0;
|
||||
if (rest !== parseInt(digits[10])) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const CpfInput = forwardRef<HTMLInputElement, CpfInputProps>(
|
||||
({ label, error, onChange, className = '', ...rest }, ref) => {
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
e.target.value = maskCpf(e.target.value);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded border bg-bg px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none${error ? ' border-danger' : ''}`}
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CpfInput.displayName = 'CpfInput';
|
||||
67
src/components/ui/CurrencyInput.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ChangeEvent } from 'react';
|
||||
|
||||
interface CurrencyInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function maskCurrency(value: string): string {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) return '';
|
||||
|
||||
const cents = parseInt(digits, 10);
|
||||
const reais = (cents / 100).toFixed(2);
|
||||
|
||||
const [intPart, decPart] = reais.split('.');
|
||||
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
return `${formattedInt},${decPart}`;
|
||||
}
|
||||
|
||||
export function parseCurrencyToNumber(masked: string): number | undefined {
|
||||
if (!masked) return undefined;
|
||||
const cleaned = masked.replace(/\./g, '').replace(',', '.');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) || num <= 0 ? undefined : num;
|
||||
}
|
||||
|
||||
export function numberToCurrencyString(value: number | string | null | undefined): string {
|
||||
if (value == null) return '';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '';
|
||||
const fixed = num.toFixed(2);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
return `${formattedInt},${decPart}`;
|
||||
}
|
||||
|
||||
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
({ label, error, onChange, className = '', ...rest }, ref) => {
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
e.target.value = maskCurrency(e.target.value);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-small">
|
||||
R$
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
inputMode="numeric"
|
||||
className={`w-full rounded border bg-bg pl-9 pr-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none${error ? ' border-danger' : ''}`}
|
||||
placeholder="0,00"
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CurrencyInput.displayName = 'CurrencyInput';
|
||||
76
src/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
rowKey: keyof T | ((item: T) => string);
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
emptyMessage = 'Nenhum registro encontrado',
|
||||
rowKey,
|
||||
}: DataTableProps<T>) {
|
||||
function getRowKey(item: T): string {
|
||||
if (typeof rowKey === 'function') return rowKey(item);
|
||||
return String(item[rowKey]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border bg-card">
|
||||
<table className="w-full text-body">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-small text-text-secondary">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="px-4 py-3 font-medium">
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="px-4 py-3" colSpan={columns.length}>
|
||||
<div className="h-4 w-full animate-pulse rounded bg-hover" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={getRowKey(item)}
|
||||
className="border-b last:border-0 transition-colors hover:bg-hover"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{col.render
|
||||
? col.render(item)
|
||||
: String((item as Record<string, unknown>)[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/ui/DatePicker.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
interface DatePickerProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
label,
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'dd/mm/aaaa',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
|
||||
const selectedDate = value ? parseLocalDate(value) : undefined;
|
||||
const displayValue = selectedDate ? format(selectedDate, 'dd/MM/yyyy') : '';
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(day: Date | undefined) => {
|
||||
if (day) {
|
||||
onChange?.(format(day, 'yyyy-MM-dd'));
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setFlipUp(spaceBelow < 340);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className={className} ref={containerRef}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`flex w-full items-center rounded border bg-bg px-3 py-2 text-body text-left focus:border-isis-blue focus:outline-none${
|
||||
error ? ' border-danger' : ''
|
||||
}${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<span className={displayValue ? 'text-primary' : 'text-text-muted'}>
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<Calendar className="ml-auto h-4 w-4 flex-shrink-0 text-text-muted" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={`absolute left-0 z-50 mt-1 rounded-lg border border-border bg-card p-3 shadow-lg ${
|
||||
flipUp ? 'bottom-full mb-1' : 'top-full'
|
||||
}`}
|
||||
>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
locale={ptBR}
|
||||
showOutsideDays
|
||||
classNames={{
|
||||
root: 'text-primary relative',
|
||||
months: 'flex gap-4',
|
||||
month_caption:
|
||||
'flex justify-center py-1 text-small font-semibold text-primary capitalize',
|
||||
nav: 'absolute top-1 left-1 right-1 flex items-center justify-between z-10',
|
||||
button_previous:
|
||||
'h-7 w-7 flex items-center justify-center rounded hover:bg-hover text-text-secondary',
|
||||
button_next:
|
||||
'h-7 w-7 flex items-center justify-center rounded hover:bg-hover text-text-secondary',
|
||||
weekdays: 'flex',
|
||||
weekday: 'w-9 text-center text-[11px] font-medium text-text-muted',
|
||||
week: 'flex',
|
||||
day: 'h-9 w-9 flex items-center justify-center text-small rounded hover:bg-hover cursor-pointer',
|
||||
day_button:
|
||||
'h-9 w-9 flex items-center justify-center text-small rounded hover:bg-hover cursor-pointer',
|
||||
selected: 'bg-isis-blue text-white hover:bg-isis-blue/90',
|
||||
today: 'font-bold border border-isis-blue',
|
||||
outside: 'text-text-muted opacity-50',
|
||||
disabled: 'text-text-muted opacity-30 cursor-not-allowed',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/DatePickerField.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Controller, type Control, type FieldValues, type Path } from 'react-hook-form';
|
||||
import { DatePicker } from './DatePicker';
|
||||
|
||||
interface DatePickerFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
control: Control<T>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DatePickerField<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
}: DatePickerFieldProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
label={label}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
288
src/components/ui/DateRangePicker.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { DayPicker, type DateRange } from 'react-day-picker';
|
||||
import {
|
||||
format,
|
||||
subDays,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
subMonths,
|
||||
isEqual,
|
||||
startOfDay,
|
||||
} from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Calendar, X } from 'lucide-react';
|
||||
|
||||
interface Preset {
|
||||
label: string;
|
||||
getRange: () => { start: Date; end: Date };
|
||||
}
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
label: 'Últimos 7 dias',
|
||||
getRange: () => ({ start: subDays(new Date(), 6), end: new Date() }),
|
||||
},
|
||||
{
|
||||
label: 'Últimos 15 dias',
|
||||
getRange: () => ({ start: subDays(new Date(), 14), end: new Date() }),
|
||||
},
|
||||
{
|
||||
label: 'Últimos 30 dias',
|
||||
getRange: () => ({ start: subDays(new Date(), 29), end: new Date() }),
|
||||
},
|
||||
{
|
||||
label: 'Últimos 90 dias',
|
||||
getRange: () => ({ start: subDays(new Date(), 89), end: new Date() }),
|
||||
},
|
||||
{
|
||||
label: 'Este mês',
|
||||
getRange: () => ({ start: startOfMonth(new Date()), end: new Date() }),
|
||||
},
|
||||
{
|
||||
label: 'Mês passado',
|
||||
getRange: () => {
|
||||
const prev = subMonths(new Date(), 1);
|
||||
return { start: startOfMonth(prev), end: endOfMonth(prev) };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChangeStart: (value: string) => void;
|
||||
onChangeEnd: (value: string) => void;
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseLocalDate(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function toStr(date: Date): string {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onChangeStart,
|
||||
onChangeEnd,
|
||||
onClear,
|
||||
className = '',
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
|
||||
// Estado pendente para seleção de range — só emite quando ambas datas estão selecionadas
|
||||
const [pendingRange, setPendingRange] = useState<DateRange | undefined>(undefined);
|
||||
|
||||
const committedFrom = parseLocalDate(startDate);
|
||||
const committedTo = parseLocalDate(endDate);
|
||||
|
||||
// Enquanto o popover está aberto e há seleção pendente, mostra o pendente no calendário
|
||||
const calendarSelected: DateRange | undefined =
|
||||
open && pendingRange
|
||||
? pendingRange
|
||||
: committedFrom || committedTo
|
||||
? { from: committedFrom, to: committedTo }
|
||||
: undefined;
|
||||
|
||||
const hasValue = startDate || endDate;
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (committedFrom && committedTo)
|
||||
return `${format(committedFrom, 'dd/MM/yyyy')} — ${format(committedTo, 'dd/MM/yyyy')}`;
|
||||
if (committedFrom) return `${format(committedFrom, 'dd/MM/yyyy')} — ...`;
|
||||
if (committedTo) return `... — ${format(committedTo, 'dd/MM/yyyy')}`;
|
||||
return '';
|
||||
}, [committedFrom, committedTo]);
|
||||
|
||||
const activePresetIndex = useMemo(() => {
|
||||
if (!committedFrom || !committedTo) return -1;
|
||||
return PRESETS.findIndex((p) => {
|
||||
const r = p.getRange();
|
||||
return (
|
||||
isEqual(startOfDay(committedFrom), startOfDay(r.start)) &&
|
||||
isEqual(startOfDay(committedTo), startOfDay(r.end))
|
||||
);
|
||||
});
|
||||
}, [committedFrom, committedTo]);
|
||||
|
||||
function handleRangeSelect(range: DateRange | undefined) {
|
||||
if (!range) {
|
||||
setPendingRange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.from && range.to) {
|
||||
// Range completo — emitir e fechar
|
||||
onChangeStart(toStr(range.from));
|
||||
onChangeEnd(toStr(range.to));
|
||||
setPendingRange(undefined);
|
||||
setOpen(false);
|
||||
} else {
|
||||
// Só data de início selecionada — guardar como pendente, NÃO emitir
|
||||
setPendingRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreset(preset: Preset) {
|
||||
const { start, end } = preset.getRange();
|
||||
onChangeStart(toStr(start));
|
||||
onChangeEnd(toStr(end));
|
||||
setPendingRange(undefined);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onClear?.();
|
||||
setPendingRange(undefined);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
// Resetar pending ao abrir
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPendingRange(undefined);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setFlipUp(spaceBelow < 420);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-2 text-small transition-colors hover:border-isis-blue/40"
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-text-muted" />
|
||||
<span className={hasValue ? 'text-primary' : 'text-text-muted'}>
|
||||
{displayValue || 'Selecione o período'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-muted transition-colors hover:bg-hover hover:text-primary"
|
||||
title="Limpar filtro"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute left-0 z-50 mt-1 flex rounded-lg border border-border bg-card shadow-lg ${
|
||||
flipUp ? 'bottom-full mb-1' : 'top-full'
|
||||
}`}
|
||||
>
|
||||
{/* Atalhos */}
|
||||
<div className="flex flex-col border-r border-border p-3" style={{ minWidth: '160px' }}>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
|
||||
Atalhos
|
||||
</p>
|
||||
{PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => handlePreset(preset)}
|
||||
className={`rounded px-3 py-2 text-left text-small transition-colors ${
|
||||
activePresetIndex === i
|
||||
? 'bg-isis-blue/10 font-medium text-isis-blue'
|
||||
: 'text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
{hasValue && (
|
||||
<>
|
||||
<div className="my-2 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="rounded px-3 py-2 text-left text-small text-danger hover:bg-hover"
|
||||
>
|
||||
Limpar filtro
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendário */}
|
||||
<div className="p-3">
|
||||
<DayPicker
|
||||
mode="range"
|
||||
selected={calendarSelected}
|
||||
onSelect={handleRangeSelect}
|
||||
numberOfMonths={2}
|
||||
locale={ptBR}
|
||||
showOutsideDays
|
||||
classNames={{
|
||||
root: 'text-primary relative',
|
||||
months: 'flex gap-4',
|
||||
month_caption:
|
||||
'flex justify-center py-1 text-small font-semibold text-primary capitalize',
|
||||
nav: 'absolute top-1 left-1 right-1 flex items-center justify-between z-10',
|
||||
button_previous:
|
||||
'h-7 w-7 flex items-center justify-center rounded hover:bg-hover text-text-secondary',
|
||||
button_next:
|
||||
'h-7 w-7 flex items-center justify-center rounded hover:bg-hover text-text-secondary',
|
||||
weekdays: 'flex',
|
||||
weekday: 'w-9 text-center text-[11px] font-medium text-text-muted',
|
||||
week: 'flex',
|
||||
day: 'h-9 w-9 flex items-center justify-center text-small rounded cursor-pointer',
|
||||
day_button:
|
||||
'h-9 w-9 flex items-center justify-center text-small rounded cursor-pointer hover:bg-hover',
|
||||
selected: 'bg-isis-blue text-white hover:bg-isis-blue/90',
|
||||
range_start: 'bg-isis-blue text-white rounded-l-full',
|
||||
range_end: 'bg-isis-blue text-white rounded-r-full',
|
||||
range_middle: 'bg-isis-blue/10 text-primary',
|
||||
today: 'font-bold border border-isis-blue',
|
||||
outside: 'text-text-muted opacity-50',
|
||||
disabled: 'text-text-muted opacity-30 cursor-not-allowed',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/ui/DetailField.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface DetailFieldProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
export function DetailField({ label, value }: DetailFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-small text-text-muted">{label}</span>
|
||||
<span className="text-body text-primary">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/ui/Drawer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export function Drawer({ open, title, onClose, children, width = 'max-w-lg' }: DrawerProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}
|
||||
}, [open, handleKeyDown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/60 transition-opacity" />
|
||||
<div
|
||||
className={`relative ${width} w-full animate-slide-in-right h-full overflow-y-auto border-l bg-card shadow-xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b bg-card px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-primary">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-primary"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
32
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', ...rest }, ref) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">{icon}</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded border bg-bg px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none${
|
||||
icon ? ' pl-9' : ''
|
||||
}${error ? ' border-danger' : ''}`}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
53
src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemLabel: string;
|
||||
itemLabelPlural: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemLabel,
|
||||
itemLabelPlural,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between text-small text-text-secondary">
|
||||
<span>
|
||||
{totalItems} {totalItems === 1 ? itemLabel : itemLabelPlural}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
icon={<ChevronLeft size={14} />}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span>
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
Próximo
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/PhoneInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ChangeEvent } from 'react';
|
||||
|
||||
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function maskPhone(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 11);
|
||||
if (digits.length <= 10) {
|
||||
return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d{1,4})$/, '$1-$2');
|
||||
}
|
||||
return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d{1,4})$/, '$1-$2');
|
||||
}
|
||||
|
||||
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
|
||||
({ label, error, onChange, className = '', ...rest }, ref) => {
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
e.target.value = maskPhone(e.target.value);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded border bg-bg px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none${error ? ' border-danger' : ''}`}
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PhoneInput.displayName = 'PhoneInput';
|
||||
50
src/components/ui/SearchInput.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Buscar...',
|
||||
debounceMs = 300,
|
||||
}: SearchInputProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
function handleChange(newValue: string) {
|
||||
setLocalValue(newValue);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
onChange(newValue);
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded border bg-bg pl-9 pr-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, options, placeholder, error, className = '', ...rest }, ref) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <label className="mb-1 block text-small text-text-secondary">{label}</label>}
|
||||
<select
|
||||
ref={ref}
|
||||
className={`w-full rounded border bg-bg px-3 py-2 text-body text-primary focus:border-isis-blue focus:outline-none${
|
||||
error ? ' border-danger' : ''
|
||||
}`}
|
||||
{...rest}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1 text-small text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
68
src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: TabItem[];
|
||||
activeTab?: string;
|
||||
defaultTab?: string;
|
||||
onChange?: (tabId: string) => void;
|
||||
children: (activeTabId: string) => ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, activeTab, defaultTab, onChange, children }: TabsProps) {
|
||||
const [internalTab, setInternalTab] = useState(defaultTab ?? tabs[0]?.id ?? '');
|
||||
const currentTab = activeTab ?? internalTab;
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
if (activeTab === undefined) {
|
||||
setInternalTab(tabId);
|
||||
}
|
||||
onChange?.(tabId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="tablist" className="flex border-b border-border-strong">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
id={`tab-${tab.id}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors duration-200 border-b-2 -mb-px cursor-pointer ${
|
||||
isActive
|
||||
? 'border-isis-blue text-isis-blue'
|
||||
: 'border-transparent text-muted hover:text-secondary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={`ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium ${
|
||||
isActive ? 'bg-isis-blue/20 text-isis-blue' : 'bg-hover text-muted'
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div role="tabpanel" id={`tabpanel-${currentTab}`} aria-labelledby={`tab-${currentTab}`}>
|
||||
{children(currentTab)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-hover text-secondary hover:text-primary transition-colors duration-150"
|
||||
aria-label={theme === 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun size={18} className="transition-transform duration-300 rotate-0 hover:rotate-45" />
|
||||
) : (
|
||||
<Moon size={18} className="transition-transform duration-300 rotate-0 hover:-rotate-12" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
75
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
role="alert"
|
||||
className={`flex items-center gap-2 rounded px-4 py-2 text-body text-white shadow-lg animate-in slide-in-from-right ${
|
||||
toast.type === 'success'
|
||||
? 'bg-success'
|
||||
: toast.type === 'warning'
|
||||
? 'bg-warning'
|
||||
: 'bg-danger'
|
||||
}`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-2 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
38
src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
position?: TooltipPosition;
|
||||
}
|
||||
|
||||
const positionClasses: Record<TooltipPosition, string> = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
};
|
||||
|
||||
export function Tooltip({ content, children, position = 'top' }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setVisible(true)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
>
|
||||
{children}
|
||||
{visible && (
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`absolute z-50 whitespace-nowrap rounded border bg-card px-2 py-1 text-small text-primary shadow-lg ${positionClasses[position]}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/__tests__/Badge.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Badge } from '../Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it.each([
|
||||
['success', 'bg-success/20', 'text-success'],
|
||||
['danger', 'bg-danger/20', 'text-danger'],
|
||||
['warning', 'bg-warning/20', 'text-warning'],
|
||||
['info', 'bg-info/20', 'text-info'],
|
||||
['primary', 'bg-isis-blue/20', 'text-isis-blue'],
|
||||
] as const)('renders %s variant with correct classes', (variant, bgClass, textClass) => {
|
||||
render(<Badge variant={variant}>Label</Badge>);
|
||||
|
||||
const badge = screen.getByText('Label');
|
||||
expect(badge.className).toContain(bgClass);
|
||||
expect(badge.className).toContain(textClass);
|
||||
});
|
||||
|
||||
it('renders children content', () => {
|
||||
render(<Badge variant="success">Ativo</Badge>);
|
||||
expect(screen.getByText('Ativo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies sm size classes', () => {
|
||||
render(
|
||||
<Badge variant="info" size="sm">
|
||||
Small
|
||||
</Badge>,
|
||||
);
|
||||
const badge = screen.getByText('Small');
|
||||
expect(badge.className).toContain('px-1.5');
|
||||
});
|
||||
});
|
||||
69
src/components/ui/__tests__/Breadcrumbs.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Breadcrumbs } from '../Breadcrumbs';
|
||||
import type { BreadcrumbItem } from '../Breadcrumbs';
|
||||
|
||||
function renderBreadcrumbs(items: BreadcrumbItem[]) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Breadcrumbs items={items} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Breadcrumbs', () => {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: 'Início', to: '/' },
|
||||
{ label: 'Usuários', to: '/usuarios' },
|
||||
{ label: 'Detalhes' },
|
||||
];
|
||||
|
||||
it('renderiza todos os items fornecidos', () => {
|
||||
renderBreadcrumbs(items);
|
||||
|
||||
expect(screen.getByText('Início')).toBeInTheDocument();
|
||||
expect(screen.getByText('Usuários')).toBeInTheDocument();
|
||||
expect(screen.getByText('Detalhes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renderiza separadores ChevronRight entre items', () => {
|
||||
const { container } = renderBreadcrumbs(items);
|
||||
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs).toHaveLength(items.length - 1);
|
||||
});
|
||||
|
||||
it('items com to renderizam como Link clicável', () => {
|
||||
renderBreadcrumbs(items);
|
||||
|
||||
const inicio = screen.getByText('Início');
|
||||
expect(inicio.tagName).toBe('A');
|
||||
expect(inicio).toHaveAttribute('href', '/');
|
||||
|
||||
const usuarios = screen.getByText('Usuários');
|
||||
expect(usuarios.tagName).toBe('A');
|
||||
expect(usuarios).toHaveAttribute('href', '/usuarios');
|
||||
});
|
||||
|
||||
it('último item renderiza como span sem link', () => {
|
||||
renderBreadcrumbs(items);
|
||||
|
||||
const detalhes = screen.getByText('Detalhes');
|
||||
expect(detalhes.tagName).toBe('SPAN');
|
||||
expect(detalhes).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('renderiza nav com aria-label correto', () => {
|
||||
renderBreadcrumbs(items);
|
||||
|
||||
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('não renderiza nada quando array está vazio', () => {
|
||||
const { container } = renderBreadcrumbs([]);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
64
src/components/ui/__tests__/DataTable.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataTable } from '../DataTable';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', header: 'Nome' },
|
||||
{ key: 'email', header: 'E-mail' },
|
||||
];
|
||||
|
||||
const data: User[] = [
|
||||
{ id: '1', name: 'Alice', email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', email: 'bob@test.com' },
|
||||
];
|
||||
|
||||
describe('DataTable', () => {
|
||||
it('renders columns and data correctly', () => {
|
||||
render(<DataTable columns={columns} data={data} rowKey="id" />);
|
||||
|
||||
expect(screen.getByText('Nome')).toBeInTheDocument();
|
||||
expect(screen.getByText('E-mail')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob@test.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows skeleton when isLoading is true', () => {
|
||||
render(<DataTable columns={columns} data={[]} rowKey="id" isLoading />);
|
||||
|
||||
const skeletons = document.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBe(5);
|
||||
});
|
||||
|
||||
it('shows empty state when data is empty', () => {
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={[]}
|
||||
rowKey="id"
|
||||
emptyMessage="Nenhum usuário encontrado"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nenhum usuário encontrado')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom render function for columns', () => {
|
||||
const columnsWithRender = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Nome',
|
||||
render: (item: User) => <strong>{item.name.toUpperCase()}</strong>,
|
||||
},
|
||||
];
|
||||
|
||||
render(<DataTable columns={columnsWithRender} data={data} rowKey="id" />);
|
||||
|
||||
expect(screen.getByText('ALICE')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
src/components/ui/__tests__/Input.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
import { Input } from '../Input';
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders label, placeholder and error message', () => {
|
||||
render(<Input label="Nome" placeholder="Digite seu nome" error="Campo obrigatório" />);
|
||||
|
||||
expect(screen.getByText('Nome')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Digite seu nome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Campo obrigatório')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
render(<Input icon={<span data-testid="icon">🔍</span>} />);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards ref correctly for React Hook Form', () => {
|
||||
const ref = createRef<HTMLInputElement>();
|
||||
render(<Input ref={ref} placeholder="test" />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
||||
expect(ref.current?.placeholder).toBe('test');
|
||||
});
|
||||
|
||||
it('passes through native input props', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Input type="email" disabled onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveAttribute('type', 'email');
|
||||
});
|
||||
});
|
||||
108
src/components/ui/__tests__/Pagination.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Pagination } from '../Pagination';
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders total count and page buttons', () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={3}
|
||||
totalItems={50}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('50 usuários')).toBeInTheDocument();
|
||||
expect(screen.getByText('Página 1 de 3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Anterior')).toBeInTheDocument();
|
||||
expect(screen.getByText('Próximo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses singular label when totalItems is 1', () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={1}
|
||||
totalItems={1}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('1 usuário')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables "Anterior" button on first page', () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={3}
|
||||
totalItems={50}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Anterior').closest('button')).toBeDisabled();
|
||||
expect(screen.getByText('Próximo').closest('button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables "Próximo" button on last page', () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={3}
|
||||
totalPages={3}
|
||||
totalItems={50}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Próximo').closest('button')).toBeDisabled();
|
||||
expect(screen.getByText('Anterior').closest('button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onPageChange with correct page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPageChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={3}
|
||||
totalItems={50}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Anterior').closest('button')!);
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
await user.click(screen.getByText('Próximo').closest('button')!);
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('renders nothing when totalItems is 0', () => {
|
||||
const { container } = render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={0}
|
||||
totalItems={0}
|
||||
itemLabel="usuário"
|
||||
itemLabelPlural="usuários"
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
44
src/components/ui/__tests__/SearchInput.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SearchInput } from '../SearchInput';
|
||||
|
||||
describe('SearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls onChange after debounce', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<SearchInput value="" onChange={onChange} debounceMs={300} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Buscar...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('renders search icon', () => {
|
||||
vi.useRealTimers();
|
||||
render(<SearchInput value="" onChange={() => {}} />);
|
||||
const svg = document.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom placeholder', () => {
|
||||
vi.useRealTimers();
|
||||
render(<SearchInput value="" onChange={() => {}} placeholder="Buscar por nome..." />);
|
||||
expect(screen.getByPlaceholderText('Buscar por nome...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
src/components/ui/__tests__/Select.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
import { Select } from '../Select';
|
||||
|
||||
const options = [
|
||||
{ value: 'admin', label: 'Administrador' },
|
||||
{ value: 'operator', label: 'Operador' },
|
||||
];
|
||||
|
||||
describe('Select', () => {
|
||||
it('renders options and placeholder', () => {
|
||||
render(<Select options={options} placeholder="Selecione um perfil" />);
|
||||
|
||||
expect(screen.getByText('Selecione um perfil')).toBeInTheDocument();
|
||||
expect(screen.getByText('Administrador')).toBeInTheDocument();
|
||||
expect(screen.getByText('Operador')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label and error', () => {
|
||||
render(<Select options={options} label="Perfil" error="Selecione um perfil" />);
|
||||
|
||||
expect(screen.getByText('Perfil')).toBeInTheDocument();
|
||||
expect(screen.getByText('Selecione um perfil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards ref correctly for React Hook Form', () => {
|
||||
const ref = createRef<HTMLSelectElement>();
|
||||
render(<Select ref={ref} options={options} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLSelectElement);
|
||||
});
|
||||
});
|
||||
103
src/components/ui/__tests__/Tabs.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Tabs } from '../Tabs';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'resumo', label: 'Resumo' },
|
||||
{ id: 'equipe', label: 'Equipe', count: 3 },
|
||||
{ id: 'timeline', label: 'Timeline' },
|
||||
];
|
||||
|
||||
function renderContent(activeTabId: string) {
|
||||
return <div>Conteúdo: {activeTabId}</div>;
|
||||
}
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('renderiza todas as abas passadas como prop', () => {
|
||||
render(<Tabs tabs={tabs}>{renderContent}</Tabs>);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /Resumo/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Equipe/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Timeline/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('destaca aba ativa com aria-selected', () => {
|
||||
render(
|
||||
<Tabs tabs={tabs} defaultTab="equipe">
|
||||
{renderContent}
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /Equipe/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /Resumo/ })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('alterna conteúdo ao clicar em aba diferente', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Tabs tabs={tabs}>{renderContent}</Tabs>);
|
||||
|
||||
expect(screen.getByText('Conteúdo: resumo')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Timeline/ }));
|
||||
|
||||
expect(screen.getByText('Conteúdo: timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('possui atributos de acessibilidade corretos', () => {
|
||||
render(<Tabs tabs={tabs}>{renderContent}</Tabs>);
|
||||
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(3);
|
||||
expect(screen.getByRole('tabpanel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('respeita defaultTab ao inicializar', () => {
|
||||
render(
|
||||
<Tabs tabs={tabs} defaultTab="timeline">
|
||||
{renderContent}
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /Timeline/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('Conteúdo: timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('chama onChange ao trocar de aba', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tabs tabs={tabs} onChange={onChange}>
|
||||
{renderContent}
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Equipe/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('equipe');
|
||||
});
|
||||
|
||||
it('exibe badge de contagem quando count é definido', () => {
|
||||
render(<Tabs tabs={tabs}>{renderContent}</Tabs>);
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('funciona em modo controlado com activeTab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tabs tabs={tabs} activeTab="resumo" onChange={onChange}>
|
||||
{renderContent}
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Conteúdo: resumo')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Timeline/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('timeline');
|
||||
// Continua mostrando resumo pois é controlado externamente
|
||||
expect(screen.getByText('Conteúdo: resumo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
src/components/ui/__tests__/ThemeSwitcher.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ThemeProvider } from '../../../app/providers/ThemeProvider';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher';
|
||||
|
||||
function renderWithTheme(initialTheme: 'light' | 'dark') {
|
||||
localStorage.setItem('@iasis:theme', initialTheme);
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<ThemeSwitcher />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
});
|
||||
|
||||
describe('ThemeSwitcher', () => {
|
||||
it('renders Sun icon when theme is dark', () => {
|
||||
renderWithTheme('dark');
|
||||
expect(screen.getByLabelText('Mudar para tema claro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Moon icon when theme is light', () => {
|
||||
renderWithTheme('light');
|
||||
expect(screen.getByLabelText('Mudar para tema escuro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles theme on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme('dark');
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByLabelText('Mudar para tema escuro')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('@iasis:theme')).toBe('light');
|
||||
});
|
||||
});
|
||||
34
src/components/ui/__tests__/Tooltip.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('shows content on hover and hides on mouse leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Tooltip content="Editar usuário">
|
||||
<button>Editar</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
|
||||
await user.hover(screen.getByText('Editar'));
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Editar usuário');
|
||||
|
||||
await user.unhover(screen.getByText('Editar'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<Tooltip content="Dica">
|
||||
<span>Conteúdo</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Conteúdo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
22
src/components/ui/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export type { BreadcrumbItem } from './Breadcrumbs';
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { Select } from './Select';
|
||||
export { Badge } from './Badge';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { Tooltip } from './Tooltip';
|
||||
export { DataTable } from './DataTable';
|
||||
export { Pagination } from './Pagination';
|
||||
export { ConfirmDialog } from './ConfirmDialog';
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
export { ThemeSwitcher } from './ThemeSwitcher';
|
||||
export { Drawer } from './Drawer';
|
||||
export { DetailField } from './DetailField';
|
||||
export { CpfInput, isValidCpf } from './CpfInput';
|
||||
export { PhoneInput } from './PhoneInput';
|
||||
export { CpfCnpjInput, isValidCnpj, isValidCpfCnpj } from './CpfCnpjInput';
|
||||
export { CurrencyInput, parseCurrencyToNumber, numberToCurrencyString } from './CurrencyInput';
|
||||
export { DatePicker } from './DatePicker';
|
||||
export { DatePickerField } from './DatePickerField';
|
||||
export { DateRangePicker } from './DateRangePicker';
|
||||
0
src/constants/.gitkeep
Normal file
63
src/constants/__tests__/breadcrumbs.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { breadcrumbRoutes } from '../breadcrumbs';
|
||||
|
||||
const expectedRoutes = [
|
||||
'/',
|
||||
'/entregaveis',
|
||||
'/entregaveis/:id',
|
||||
'/ordens-servico',
|
||||
'/sprints',
|
||||
'/profissionais',
|
||||
'/usuarios',
|
||||
'/usuarios/novo',
|
||||
'/usuarios/:id/editar',
|
||||
'/clientes',
|
||||
'/configuracoes',
|
||||
];
|
||||
|
||||
describe('breadcrumbRoutes', () => {
|
||||
it('cobre todas as rotas do sistema', () => {
|
||||
const patterns = breadcrumbRoutes.map((r) => r.pattern);
|
||||
for (const route of expectedRoutes) {
|
||||
expect(patterns).toContain(route);
|
||||
}
|
||||
});
|
||||
|
||||
it('cada configuração tem pelo menos um item', () => {
|
||||
for (const route of breadcrumbRoutes) {
|
||||
expect(route.items.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('último item de cada configuração não tem "to"', () => {
|
||||
for (const route of breadcrumbRoutes) {
|
||||
const lastItem = route.items[route.items.length - 1];
|
||||
expect(lastItem.to).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rotas com parâmetros usam padrão :param', () => {
|
||||
const dynamicRoutes = breadcrumbRoutes.filter((r) => r.pattern.includes(':'));
|
||||
expect(dynamicRoutes.length).toBeGreaterThan(0);
|
||||
for (const route of dynamicRoutes) {
|
||||
expect(route.pattern).toMatch(/:\w+/);
|
||||
}
|
||||
});
|
||||
|
||||
it('Dashboard é sempre a raiz dos breadcrumbs', () => {
|
||||
for (const route of breadcrumbRoutes) {
|
||||
expect(route.items[0].label).toBe('Dashboard');
|
||||
}
|
||||
});
|
||||
|
||||
it('página Dashboard não tem link no item', () => {
|
||||
const dashboardRoute = breadcrumbRoutes.find((r) => r.pattern === '/');
|
||||
expect(dashboardRoute?.items[0].to).toBeUndefined();
|
||||
});
|
||||
|
||||
it('não inclui rotas de autenticação', () => {
|
||||
const patterns = breadcrumbRoutes.map((r) => r.pattern);
|
||||
expect(patterns).not.toContain('/login');
|
||||
expect(patterns).not.toContain('/trocar-senha');
|
||||
});
|
||||
});
|
||||
143
src/constants/breadcrumbs.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { BreadcrumbItem } from '../components/ui/Breadcrumbs';
|
||||
|
||||
export interface BreadcrumbRouteConfig {
|
||||
pattern: string;
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
const dashboard: BreadcrumbItem = { label: 'Dashboard', to: '/' };
|
||||
|
||||
export const breadcrumbRoutes: BreadcrumbRouteConfig[] = [
|
||||
{ pattern: '/', items: [{ label: 'Dashboard' }] },
|
||||
{ pattern: '/entregaveis', items: [dashboard, { label: 'Entregáveis' }] },
|
||||
{
|
||||
pattern: '/entregaveis/novo',
|
||||
items: [dashboard, { label: 'Entregáveis', to: '/entregaveis' }, { label: 'Novo Entregável' }],
|
||||
},
|
||||
{
|
||||
pattern: '/entregaveis/:id',
|
||||
items: [dashboard, { label: 'Entregáveis', to: '/entregaveis' }, { label: 'Entregável #:id' }],
|
||||
},
|
||||
{
|
||||
pattern: '/entregaveis/:id/editar',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Entregáveis', to: '/entregaveis' },
|
||||
{ label: 'Editar Entregável' },
|
||||
],
|
||||
},
|
||||
{ pattern: '/ordens-servico', items: [dashboard, { label: 'Ordens de Serviço' }] },
|
||||
{
|
||||
pattern: '/ordens-servico/nova',
|
||||
items: [dashboard, { label: 'Ordens de Serviço', to: '/ordens-servico' }, { label: 'Nova OS' }],
|
||||
},
|
||||
{
|
||||
pattern: '/ordens-servico/:id',
|
||||
items: [dashboard, { label: 'Ordens de Serviço', to: '/ordens-servico' }, { label: ':name' }],
|
||||
},
|
||||
{
|
||||
pattern: '/ordens-servico/:id/editar',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Ordens de Serviço', to: '/ordens-servico' },
|
||||
{ label: 'Editar OS' },
|
||||
],
|
||||
},
|
||||
{ pattern: '/sprints', items: [dashboard, { label: 'Sprints' }] },
|
||||
{
|
||||
pattern: '/sprints/novo',
|
||||
items: [dashboard, { label: 'Sprints', to: '/sprints' }, { label: 'Nova Sprint' }],
|
||||
},
|
||||
{
|
||||
pattern: '/sprints/:id',
|
||||
items: [dashboard, { label: 'Sprints', to: '/sprints' }, { label: ':name' }],
|
||||
},
|
||||
{
|
||||
pattern: '/sprints/:id/editar',
|
||||
items: [dashboard, { label: 'Sprints', to: '/sprints' }, { label: 'Editar :name' }],
|
||||
},
|
||||
{ pattern: '/profissionais', items: [dashboard, { label: 'Profissionais' }] },
|
||||
{
|
||||
pattern: '/profissionais/novo',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Profissionais', to: '/profissionais' },
|
||||
{ label: 'Novo Profissional' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: '/profissionais/:id/editar',
|
||||
items: [dashboard, { label: 'Profissionais', to: '/profissionais' }, { label: 'Editar :name' }],
|
||||
},
|
||||
{ pattern: '/usuarios', items: [dashboard, { label: 'Usuários' }] },
|
||||
{
|
||||
pattern: '/usuarios/novo',
|
||||
items: [dashboard, { label: 'Usuários', to: '/usuarios' }, { label: 'Novo Usuário' }],
|
||||
},
|
||||
{
|
||||
pattern: '/usuarios/:id/editar',
|
||||
items: [dashboard, { label: 'Usuários', to: '/usuarios' }, { label: 'Editar :name' }],
|
||||
},
|
||||
{ pattern: '/clientes', items: [dashboard, { label: 'Clientes' }] },
|
||||
{
|
||||
pattern: '/clientes/novo',
|
||||
items: [dashboard, { label: 'Clientes', to: '/clientes' }, { label: 'Novo Cliente' }],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id',
|
||||
items: [dashboard, { label: 'Clientes', to: '/clientes' }, { label: ':name' }],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id/editar',
|
||||
items: [dashboard, { label: 'Clientes', to: '/clientes' }, { label: 'Editar :name' }],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id/contratos/novo',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Clientes', to: '/clientes' },
|
||||
{ label: ':name' },
|
||||
{ label: 'Novo Contrato' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id/itens-contrato/novo',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Clientes', to: '/clientes' },
|
||||
{ label: ':name' },
|
||||
{ label: 'Novo Item de Contrato' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id/itens-contrato/:itemId/editar',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Clientes', to: '/clientes' },
|
||||
{ label: ':name' },
|
||||
{ label: 'Editar Item de Contrato' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: '/clientes/:id/projetos/novo',
|
||||
items: [
|
||||
dashboard,
|
||||
{ label: 'Clientes', to: '/clientes' },
|
||||
{ label: ':name' },
|
||||
{ label: 'Novo Projeto' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: '/contratos/:id/editar',
|
||||
items: [dashboard, { label: 'Clientes', to: '/clientes' }, { label: 'Editar Contrato' }],
|
||||
},
|
||||
{
|
||||
pattern: '/projetos/:id/editar',
|
||||
items: [dashboard, { label: 'Clientes', to: '/clientes' }, { label: 'Editar Projeto' }],
|
||||
},
|
||||
{ pattern: '/configuracoes', items: [dashboard, { label: 'Configurações' }] },
|
||||
{
|
||||
pattern: '/admin/integracoes/api-keys',
|
||||
items: [dashboard, { label: 'Integrações' }, { label: 'API Keys' }],
|
||||
},
|
||||
];
|
||||
28
src/constants/contract-item-type.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ContractItemType } from '../types/contract-item.types';
|
||||
|
||||
export const CONTRACT_ITEM_TYPE_LABELS: Record<ContractItemType, string> = {
|
||||
[ContractItemType.UST]: 'Unidade de Serviço Técnico (UST)',
|
||||
[ContractItemType.SAAS_LICENSE]: 'Licença SaaS',
|
||||
};
|
||||
|
||||
export interface ContractItemTypeBadgeConfig {
|
||||
label: string;
|
||||
color: 'gray' | 'blue';
|
||||
}
|
||||
|
||||
const badgeConfigMap: Record<ContractItemType, ContractItemTypeBadgeConfig> = {
|
||||
[ContractItemType.UST]: {
|
||||
label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.UST],
|
||||
color: 'gray',
|
||||
},
|
||||
[ContractItemType.SAAS_LICENSE]: {
|
||||
label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.SAAS_LICENSE],
|
||||
color: 'blue',
|
||||
},
|
||||
};
|
||||
|
||||
export function getContractItemTypeBadgeConfig(
|
||||
type: ContractItemType,
|
||||
): ContractItemTypeBadgeConfig {
|
||||
return badgeConfigMap[type];
|
||||
}
|
||||
45
src/constants/deliverable-status.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DeliverableStatus } from '../types/deliverable.types';
|
||||
|
||||
type StatusVariant =
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'primary'
|
||||
| 'purple'
|
||||
| 'success-dark'
|
||||
| 'danger-dark'
|
||||
| 'neutral';
|
||||
|
||||
export interface StatusConfig {
|
||||
label: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
const statusConfigMap: Record<DeliverableStatus, StatusConfig> = {
|
||||
[DeliverableStatus.RASCUNHO]: { label: 'Rascunho', variant: 'neutral' },
|
||||
[DeliverableStatus.EMITIDA]: { label: 'Emitida', variant: 'info' },
|
||||
[DeliverableStatus.EM_EXECUCAO]: { label: 'Em Execução', variant: 'warning' },
|
||||
[DeliverableStatus.AGUARDANDO_VALIDACAO]: { label: 'Aguardando Validação', variant: 'warning' },
|
||||
[DeliverableStatus.EM_REVISAO]: { label: 'Em Revisão', variant: 'purple' },
|
||||
[DeliverableStatus.APROVADA]: { label: 'Aprovada', variant: 'success' },
|
||||
[DeliverableStatus.GLOSADA]: { label: 'Glosada', variant: 'danger' },
|
||||
[DeliverableStatus.ENCERRADA]: { label: 'Encerrada', variant: 'success-dark' },
|
||||
[DeliverableStatus.CANCELADA]: { label: 'Cancelada', variant: 'danger-dark' },
|
||||
[DeliverableStatus.AGUARDANDO_PAGAMENTO]: {
|
||||
label: 'Aguardando Pagamento',
|
||||
variant: 'warning',
|
||||
},
|
||||
[DeliverableStatus.PAGA]: { label: 'Paga', variant: 'success' },
|
||||
};
|
||||
|
||||
export function getStatusConfig(status: DeliverableStatus): StatusConfig {
|
||||
return statusConfigMap[status];
|
||||
}
|
||||
|
||||
export const DELIVERABLE_STATUS_OPTIONS = Object.entries(statusConfigMap).map(
|
||||
([value, config]) => ({
|
||||
value,
|
||||
label: config.label,
|
||||
}),
|
||||
);
|
||||
23
src/constants/deliverable-type.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const DeliverableType = {
|
||||
DESCOBERTA: 'DESCOBERTA',
|
||||
DESIGN: 'DESIGN',
|
||||
ARQUITETURA: 'ARQUITETURA',
|
||||
CONSTRUCAO: 'CONSTRUCAO',
|
||||
MANUTENCAO: 'MANUTENCAO',
|
||||
LICENCA: 'LICENCA',
|
||||
} as const;
|
||||
|
||||
export type DeliverableType = (typeof DeliverableType)[keyof typeof DeliverableType];
|
||||
|
||||
export const DELIVERABLE_TYPE_LABELS: Record<DeliverableType, string> = {
|
||||
[DeliverableType.DESCOBERTA]: 'Descoberta',
|
||||
[DeliverableType.DESIGN]: 'Design',
|
||||
[DeliverableType.ARQUITETURA]: 'Arquitetura',
|
||||
[DeliverableType.CONSTRUCAO]: 'Construção',
|
||||
[DeliverableType.MANUTENCAO]: 'Manutenção',
|
||||
[DeliverableType.LICENCA]: 'Licença',
|
||||
};
|
||||
|
||||
export const DELIVERABLE_TYPE_OPTIONS = Object.entries(DELIVERABLE_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||