Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-06-13 17:32:41 -03:00
commit 759e2663ec
311 changed files with 31868 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://backend.aplicativopro.com

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

214
Deploy-Coolify.md Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View 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
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/logo/logo-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/logo/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
public/site.webmanifest Normal file
View 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"}

View File

@@ -0,0 +1,6 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*"]
}
}

0
src/app/layouts/.gitkeep Normal file
View File

View File

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
export { AppProviders } from './AppProviders';
export { AuthProvider } from './AuthProvider';

View File

@@ -0,0 +1,4 @@
import { createBrowserRouter } from 'react-router-dom';
import { routes } from './routes';
export const router = createBrowserRouter(routes);

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

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

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

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom';
import { AppProviders } from '../providers';
export function RootLayout() {
return (
<AppProviders>
<Outlet />
</AppProviders>
);
}

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

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

@@ -0,0 +1 @@
export { router } from './AppRouter';

142
src/app/router/routes.tsx Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View 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>&middot;</span>
<span>{item.createdBy}</span>
</>
)}
</div>
</div>
))}
</div>
);
}

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More