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

View File

@@ -0,0 +1,153 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../../components/ui/Toast';
import { PageTitleProvider } from '../../modules/page-title/PageTitleContext';
import { EntregavelDetailPage } from '../entregaveis/EntregavelDetailPage';
import { UserCreatePage } from '../users/UserCreatePage';
import { UserEditPage } from '../users/UserEditPage';
const mockAuth = {
user: { id: '1', name: 'Admin', email: 'admin@test.com', role: 'ADMIN' },
token: 'token',
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
};
vi.mock('../../modules/auth', () => ({ useAuth: vi.fn(() => mockAuth) }));
vi.mock('../../modules/auth/useAuth', () => ({ useAuth: vi.fn(() => mockAuth) }));
const mockUserData = {
id: 'abc-123',
name: 'João Silva',
email: 'joao@test.com',
role: 'OPERATOR',
isActive: true,
};
const mockUseUserReturn = {
data: mockUserData,
isLoading: false,
isError: false,
};
vi.mock('../../hooks/useUsers', () => ({
useUser: vi.fn(() => mockUseUserReturn),
}));
vi.mock('../../services/users.service', () => ({
createUser: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock('../../hooks/useDeliverables', () => ({
useStatusHistory: vi.fn(() => ({ data: [], isLoading: false })),
useAllowedTransitions: vi.fn(() => ({ data: [], isLoading: false })),
useChangeStatus: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
useDeliverable: vi.fn(() => ({
data: {
id: '42',
code: '42',
title: 'Entregável Teste',
description: '',
status: 'RASCUNHO',
type: 'DEVELOPMENT',
startDate: null,
expectedEndDate: null,
project: { id: 'p1', name: 'Projeto Teste' },
client: { id: 'c1', name: 'Cliente Teste' },
contract: { id: 'co1', name: 'Contrato Teste' },
contractItem: null,
workOrder: null,
sprint: null,
_count: { allocations: 0, assignments: 0, backlogItems: 0, notes: 0 },
},
isLoading: false,
isError: false,
})),
}));
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderAtRoute(ui: React.ReactElement, route: string) {
return render(
<QueryClientProvider client={createQueryClient()}>
<ToastProvider>
<PageTitleProvider>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</PageTitleProvider>
</ToastProvider>
</QueryClientProvider>,
);
}
function renderWithRoutes(path: string, route: string, element: React.ReactElement) {
return render(
<QueryClientProvider client={createQueryClient()}>
<ToastProvider>
<PageTitleProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path={path} element={element} />
</Routes>
</MemoryRouter>
</PageTitleProvider>
</ToastProvider>
</QueryClientProvider>,
);
}
describe('Breadcrumbs em páginas de detalhe e formulários', () => {
it('EntregavelDetailPage exibe breadcrumb dinâmico com ID do entregável', () => {
renderWithRoutes('/entregaveis/:id', '/entregaveis/42', <EntregavelDetailPage />);
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
expect(nav).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Entregáveis' })).toHaveAttribute(
'href',
'/entregaveis',
);
expect(screen.getAllByText('Entregável #42').length).toBeGreaterThanOrEqual(1);
});
it('UserCreatePage exibe breadcrumb com link para listagem', () => {
renderAtRoute(<UserCreatePage />, '/usuarios/novo');
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
expect(nav).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Usuários' })).toHaveAttribute('href', '/usuarios');
expect(screen.getAllByText('Novo Usuário').length).toBeGreaterThanOrEqual(1);
});
it('UserCreatePage não exibe botão Voltar', () => {
renderAtRoute(<UserCreatePage />, '/usuarios/novo');
expect(screen.queryByRole('button', { name: 'Voltar' })).not.toBeInTheDocument();
});
it('UserEditPage exibe breadcrumb dinâmico com nome do usuário', () => {
renderWithRoutes('/usuarios/:id/editar', '/usuarios/abc-123/editar', <UserEditPage />);
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
expect(nav).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Usuários' })).toHaveAttribute('href', '/usuarios');
expect(screen.getByText('Editar João Silva')).toBeInTheDocument();
});
it('UserEditPage não exibe botão Voltar', () => {
renderWithRoutes('/usuarios/:id/editar', '/usuarios/abc-123/editar', <UserEditPage />);
expect(screen.queryByRole('button', { name: 'Voltar' })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import { ToastProvider } from '../../components/ui/Toast';
import { PageTitleProvider } from '../../modules/page-title/PageTitleContext';
import { DashboardPage } from '../dashboard/DashboardPage';
import { EntregaveisListPage } from '../entregaveis/EntregaveisListPage';
import { SprintsPage } from '../sprints/SprintsPage';
import { ProfessionalsPage } from '../professionals/ProfessionalsPage';
import { ClientsPage } from '../clients/ClientsPage';
import { SettingsPage } from '../settings/SettingsPage';
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 createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderAtRoute(ui: React.ReactElement, route: string) {
return render(
<QueryClientProvider client={createQueryClient()}>
<ToastProvider>
<PageTitleProvider>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</PageTitleProvider>
</ToastProvider>
</QueryClientProvider>,
);
}
describe('Breadcrumbs em páginas de listagem', () => {
it('DashboardPage não exibe breadcrumbs (single-item header omitido)', () => {
renderAtRoute(<DashboardPage />, '/');
expect(screen.queryByRole('navigation', { name: 'Breadcrumb' })).not.toBeInTheDocument();
});
it.each([
{ Page: EntregaveisListPage, route: '/entregaveis', label: 'Entregáveis' },
{ Page: SprintsPage, route: '/sprints', label: 'Sprints' },
{ Page: ProfessionalsPage, route: '/profissionais', label: 'Profissionais' },
{ Page: ClientsPage, route: '/clientes', label: 'Clientes' },
{ Page: SettingsPage, route: '/configuracoes', label: 'Configurações' },
])('$label page exibe breadcrumbs "Dashboard / $label"', ({ Page, route, label }) => {
renderAtRoute(<Page />, route);
const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
expect(nav).toBeInTheDocument();
const dashboardLink = screen.getByRole('link', { name: 'Dashboard' });
expect(dashboardLink).toHaveAttribute('href', '/');
expect(screen.getAllByText(label).length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,65 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, Copy } from 'lucide-react';
import { Button } from '../../../components/ui';
interface ApiKeyCreatedDialogProps {
open: boolean;
plainKey: string | null;
onClose: () => void;
}
export function ApiKeyCreatedDialog({ open, plainKey, onClose }: ApiKeyCreatedDialogProps) {
const [copied, setCopied] = useState(false);
if (!open || !plainKey) return null;
async function handleCopy() {
if (!plainKey) return;
await navigator.clipboard.writeText(plainKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-lg border bg-card p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold text-primary">API Key gerada</h2>
<p className="mb-4 text-small text-danger">
Esta é a única vez que você verá esta chave. Copie e armazene em local seguro.
</p>
<div className="flex items-center gap-2 rounded border bg-bg px-4 py-3">
<code
data-testid="generated-api-key"
className="flex-1 break-all font-mono text-body text-primary"
>
{plainKey}
</code>
<button
type="button"
onClick={() => void handleCopy()}
className="shrink-0 rounded p-2 text-text-muted transition-colors hover:bg-hover hover:text-primary"
title="Copiar chave"
aria-label="Copiar chave"
>
{copied ? <Check size={18} className="text-success" /> : <Copy size={18} />}
</button>
</div>
<div className="mt-6 flex justify-end">
<Button type="button" onClick={onClose}>
Fechar
</Button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,98 @@
import { Ban } from 'lucide-react';
import { Badge, Button, DataTable, Tooltip } from '../../../components/ui';
import { SCOPE_LABELS, type ApiKey } from '../../../modules/api-keys';
interface ApiKeyTableProps {
data: ApiKey[];
isLoading: boolean;
onRevoke: (key: ApiKey) => void;
}
function formatDate(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
return date.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
export function ApiKeyTable({ data, isLoading, onRevoke }: ApiKeyTableProps) {
return (
<DataTable<ApiKey>
data={data}
isLoading={isLoading}
emptyMessage="Nenhuma API Key emitida."
rowKey="id"
columns={[
{
key: 'name',
header: 'Nome',
render: (k) => (
<span className={k.isActive ? 'text-primary' : 'text-text-muted line-through'}>
{k.name}
</span>
),
},
{
key: 'scopes',
header: 'Escopos',
render: (k) => (
<div className="flex flex-wrap gap-1">
{k.scopes.map((scope) => (
<Badge key={scope} variant="info">
{SCOPE_LABELS[scope] ?? scope}
</Badge>
))}
</div>
),
},
{
key: 'lastFourChars',
header: 'Chave',
render: (k) => (
<code className="font-mono text-small text-text-secondary">
{k.lastFourChars}
</code>
),
},
{
key: 'isActive',
header: 'Status',
render: (k) => (
<Badge variant={k.isActive ? 'success' : 'danger'}>
{k.isActive ? 'Ativa' : 'Revogada'}
</Badge>
),
},
{
key: 'expiresAt',
header: 'Expiração',
render: (k) => <span className="text-text-secondary">{formatDate(k.expiresAt)}</span>,
},
{
key: 'lastUsedAt',
header: 'Último uso',
render: (k) => <span className="text-text-secondary">{formatDate(k.lastUsedAt)}</span>,
},
{
key: 'actions',
header: 'Ações',
render: (k) =>
k.isActive ? (
<Tooltip content="Revogar">
<Button
variant="ghost"
size="icon"
onClick={() => onRevoke(k)}
className="text-danger hover:text-danger/80"
aria-label="Revogar"
>
<Ban size={14} />
</Button>
</Tooltip>
) : (
<span className="text-text-muted text-small"></span>
),
},
]}
/>
);
}

View File

@@ -0,0 +1,111 @@
import { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { PageContainer, PageHeader } from '../../../components/layout';
import { Button, ConfirmDialog, Pagination } from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { useBreadcrumbs } from '../../../hooks/useBreadcrumbs';
import {
useApiKeysQuery,
useRevokeApiKey,
type ApiKey,
type CreatedApiKey,
} from '../../../modules/api-keys';
import { ApiKeyTable } from './ApiKeyTable';
import { ApiKeyCreatedDialog } from './ApiKeyCreatedDialog';
import { CreateApiKeyModal } from './CreateApiKeyModal';
const PAGE_SIZE = 20;
export function ApiKeysPage() {
const breadcrumbs = useBreadcrumbs();
const { showToast } = useToast();
const [page, setPage] = useState(1);
const [createOpen, setCreateOpen] = useState(false);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const params = useMemo(() => ({ page, pageSize: PAGE_SIZE }), [page]);
const { data, isLoading, isError } = useApiKeysQuery(params);
const revokeMutation = useRevokeApiKey();
const totalPages = data?.totalPages ?? 0;
function handleCreated(key: CreatedApiKey) {
setCreateOpen(false);
setCreatedKey(key);
showToast('API Key criada com sucesso', 'success');
}
function handleConfirmRevoke() {
if (!keyToRevoke) return;
revokeMutation.mutate(keyToRevoke.id, {
onSuccess: () => {
showToast('API Key revogada', 'success');
setKeyToRevoke(null);
},
onError: () => {
showToast('Erro ao revogar API Key', 'error');
},
});
}
return (
<PageContainer>
<PageHeader
title="API Keys de Integração"
breadcrumbs={breadcrumbs}
actions={
<Button onClick={() => setCreateOpen(true)} icon={<Plus size={16} />}>
Nova API Key
</Button>
}
/>
{isError && (
<p className="mb-4 text-small text-danger">Falha ao carregar API Keys. Tente novamente.</p>
)}
<ApiKeyTable
data={data?.data ?? []}
isLoading={isLoading}
onRevoke={(key) => setKeyToRevoke(key)}
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="chave"
itemLabelPlural="chaves"
onPageChange={setPage}
/>
<CreateApiKeyModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
/>
<ApiKeyCreatedDialog
open={!!createdKey}
plainKey={createdKey?.plainKey ?? null}
onClose={() => setCreatedKey(null)}
/>
<ConfirmDialog
open={!!keyToRevoke}
title="Revogar API Key"
message={
keyToRevoke
? `Revogar a chave "${keyToRevoke.name}"? Iasis Task perderá acesso imediatamente.`
: ''
}
confirmLabel="Revogar"
variant="danger"
loading={revokeMutation.isPending}
onConfirm={handleConfirmRevoke}
onCancel={() => setKeyToRevoke(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,189 @@
import { createPortal } from 'react-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { useState } from 'react';
import { Button, Input } from '../../../components/ui';
import {
useCreateApiKey,
type CreatedApiKey,
type IntegrationScope,
SCOPE_LABELS,
} from '../../../modules/api-keys';
const ALL_SCOPES: IntegrationScope[] = ['DELIVERABLES_READ', 'CLIENTS_READ'];
const createApiKeySchema = z
.object({
name: z
.string()
.min(3, 'Nome deve ter ao menos 3 caracteres')
.max(120, 'Nome não pode exceder 120 caracteres'),
scopes: z
.array(z.enum(['DELIVERABLES_READ', 'CLIENTS_READ']))
.min(1, 'Selecione ao menos um escopo'),
expiresAt: z.string().optional(),
rateLimitPerMinute: z
.string()
.optional()
.refine(
(v) => {
if (!v) return true;
const n = Number(v);
return Number.isInteger(n) && n >= 1 && n <= 6000;
},
{ message: 'rateLimitPerMinute deve ser inteiro entre 1 e 6000' },
),
})
.refine(
(data) => {
if (!data.expiresAt) return true;
const future = new Date(data.expiresAt);
return !Number.isNaN(future.getTime()) && future.getTime() > Date.now();
},
{ message: 'Data de expiração deve ser no futuro', path: ['expiresAt'] },
);
type FormData = z.infer<typeof createApiKeySchema>;
interface CreateApiKeyModalProps {
open: boolean;
onClose: () => void;
onCreated: (created: CreatedApiKey) => void;
}
export function CreateApiKeyModal({ open, onClose, onCreated }: CreateApiKeyModalProps) {
const [apiError, setApiError] = useState<string | null>(null);
const createMutation = useCreateApiKey();
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(createApiKeySchema),
defaultValues: { name: '', scopes: [], expiresAt: '', rateLimitPerMinute: '' },
});
const selectedScopes = watch('scopes') ?? [];
function toggleScope(scope: IntegrationScope) {
if (selectedScopes.includes(scope)) {
setValue(
'scopes',
selectedScopes.filter((s) => s !== scope),
{ shouldValidate: true },
);
} else {
setValue('scopes', [...selectedScopes, scope], { shouldValidate: true });
}
}
async function onSubmit(data: FormData) {
setApiError(null);
try {
const rateLimitNumber = data.rateLimitPerMinute ? Number(data.rateLimitPerMinute) : undefined;
const created = await createMutation.mutateAsync({
name: data.name,
scopes: data.scopes,
expiresAt:
data.expiresAt && data.expiresAt.length > 0
? new Date(data.expiresAt).toISOString()
: undefined,
rateLimitPerMinute: rateLimitNumber,
});
reset();
onCreated(created);
} catch (err) {
if (axios.isAxiosError(err)) {
const message = err.response?.data?.message;
setApiError(typeof message === 'string' ? message : 'Erro ao criar API Key');
} else {
setApiError('Erro ao criar API Key');
}
}
}
function handleClose() {
reset();
setApiError(null);
onClose();
}
if (!open) 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-lg rounded-lg border bg-card p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-4 text-lg font-semibold text-primary">Nova API Key</h2>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<Input
label="Nome"
placeholder="ex: Iasis Task — Produção"
error={errors.name?.message}
{...register('name')}
/>
<div>
<label className="mb-2 block text-small font-medium text-text-secondary">Escopos</label>
<div className="space-y-2">
{ALL_SCOPES.map((scope) => (
<label key={scope} className="flex items-center gap-2 text-body">
<input
type="checkbox"
checked={selectedScopes.includes(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 rounded border-border"
/>
<span>{SCOPE_LABELS[scope]}</span>
</label>
))}
</div>
{errors.scopes?.message && (
<p className="mt-1 text-small text-danger">{errors.scopes.message}</p>
)}
</div>
<Input
label="Expiração (opcional)"
type="datetime-local"
error={errors.expiresAt?.message}
{...register('expiresAt')}
/>
<Input
label="Rate limit por minuto (opcional, default 60)"
type="number"
min={1}
max={6000}
placeholder="60"
error={errors.rateLimitPerMinute?.message}
{...register('rateLimitPerMinute')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Criando...' : 'Criar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

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

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { useAuth } from '../../modules/auth';
import { Button } from '../../components/ui';
import { useToast } from '../../components/ui/Toast';
import { authService } from '../../modules/auth/auth.service';
import { PasswordStrengthIndicator } from '../../components/shared/PasswordStrengthIndicator';
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Senha atual é obrigatória'),
newPassword: z
.string()
.min(8, 'A senha deve ter no mínimo 8 caracteres')
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
export function ChangePasswordPage() {
const { user, updateUser } = useAuth();
const navigate = useNavigate();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const isFirstAccess = user?.mustChangePassword === true;
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
const newPasswordValue = watch('newPassword', '');
async function onSubmit(data: ChangePasswordFormData) {
setApiError(null);
try {
await authService.changePassword({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
updateUser({ mustChangePassword: false });
showToast('Senha alterada com sucesso', 'success');
navigate('/', { replace: true });
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 401) {
setApiError('Senha atual incorreta');
} else {
setApiError('Erro ao alterar senha. Tente novamente.');
}
} else {
setApiError('Erro ao alterar senha. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">
{isFirstAccess ? 'Primeiro acesso — defina sua senha' : 'Trocar senha'}
</p>
</div>
<p className="mb-6 text-small text-text-secondary">
{isFirstAccess
? 'Bem-vindo! Para continuar, você precisa definir uma nova senha.'
: 'Informe sua senha atual e escolha uma nova senha.'}
</p>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="currentPassword" className="mb-1 block text-small text-text-secondary">
{isFirstAccess ? 'Senha temporária' : 'Senha atual'}
</label>
<input
id="currentPassword"
type="password"
placeholder="••••••••"
{...register('currentPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.currentPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.currentPassword.message}
</p>
)}
</div>
<div>
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
Nova senha
</label>
<input
id="newPassword"
type="password"
placeholder="••••••••"
{...register('newPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.newPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.newPassword.message}
</p>
)}
<PasswordStrengthIndicator password={newPasswordValue} />
</div>
<div>
<label htmlFor="confirmPassword" className="mb-1 block text-small text-text-secondary">
Confirmar nova senha
</label>
<input
id="confirmPassword"
type="password"
placeholder="••••••••"
{...register('confirmPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.confirmPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
{apiError && (
<p className="text-small text-red-400" role="alert">
{apiError}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Alterando...' : 'Alterar senha'}
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { authService } from '../../modules/auth/auth.service';
import { Button } from '../../components/ui';
const forgotPasswordSchema = z.object({
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
});
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
export function ForgotPasswordPage() {
const [submitted, setSubmitted] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
async function onSubmit(data: ForgotPasswordFormData) {
setApiError(null);
try {
await authService.forgotPassword(data.email);
setSubmitted(true);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError('Erro ao processar solicitação. Tente novamente.');
} else {
setApiError('Erro ao processar solicitação. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
{submitted ? (
<div role="alert" className="text-center">
<p className="mb-4 text-body text-text-primary">
Se o e-mail informado estiver cadastrado, você receberá um link de redefinição em
breve.
</p>
<Link to="/login" className="text-small text-isis-blue hover:underline">
Voltar para o login
</Link>
</div>
) : (
<>
<h1 className="mb-2 text-center text-body font-semibold text-text-primary">
Recuperar acesso
</h1>
<p className="mb-6 text-small text-text-secondary">
Informe seu e-mail para receber um link de redefinição de senha.
</p>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
E-mail
</label>
<input
id="email"
type="email"
placeholder="seu@email.com"
{...register('email')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.email && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.email.message}
</p>
)}
</div>
{apiError && (
<p className="text-small text-red-400" role="alert">
{apiError}
</p>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Enviando...' : 'Enviar link'}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-small text-isis-blue hover:underline">
Voltar para o login
</Link>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { useAuth } from '../../modules/auth';
import { Button } from '../../components/ui';
const loginSchema = z.object({
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
password: z.string().min(1, 'Senha é obrigatória'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
setApiError(null);
try {
await login(data.email, data.password);
navigate('/', { replace: true });
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 401) {
setApiError('E-mail ou senha incorretos');
} else if (status === 403) {
setApiError('Usuário inativo');
} else {
setApiError('Erro ao realizar login. Tente novamente.');
}
} else {
setApiError('Erro ao realizar login. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
E-mail
</label>
<input
id="email"
type="email"
placeholder="seu@email.com"
{...register('email')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.email && <p className="mt-1 text-small text-red-400">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="mb-1 block text-small text-text-secondary">
Senha
</label>
<input
id="password"
type="password"
placeholder="••••••••"
{...register('password')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.password && (
<p className="mt-1 text-small text-red-400">{errors.password.message}</p>
)}
</div>
{apiError && <p className="text-small text-red-400">{apiError}</p>}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/esqueci-senha" className="text-small text-isis-blue hover:underline">
Esqueceu sua senha?
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { authService } from '../../modules/auth/auth.service';
import { Button } from '../../components/ui';
const passwordSchema = z
.object({
newPassword: z
.string()
.min(8, 'A senha deve ter no mínimo 8 caracteres')
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
type ResetPasswordFormData = z.infer<typeof passwordSchema>;
export function ResetPasswordPage() {
const { token } = useParams<{ token: string }>();
const [success, setSuccess] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [isExpired, setIsExpired] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(passwordSchema),
});
async function onSubmit(data: ResetPasswordFormData) {
setApiError(null);
setIsExpired(false);
try {
await authService.resetPassword(token!, {
newPassword: data.newPassword,
confirmPassword: data.confirmPassword,
});
setSuccess(true);
} catch (err) {
if (axios.isAxiosError(err)) {
const message: string = err.response?.data?.message ?? '';
if (message.toLowerCase().includes('expirado')) {
setIsExpired(true);
setApiError('O link de redefinição expirou.');
} else if (message) {
setApiError(message);
} else {
setApiError('Erro ao redefinir senha. Tente novamente.');
}
} else {
setApiError('Erro ao redefinir senha. Tente novamente.');
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
<div className="mb-8 text-center">
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
</div>
{success ? (
<div role="alert" className="text-center">
<p className="mb-4 text-body text-text-primary">
Senha redefinida com sucesso! Você pode fazer login.
</p>
<Link to="/login" className="text-small text-isis-blue hover:underline">
Ir para o login
</Link>
</div>
) : (
<>
<h1 className="mb-6 text-center text-body font-semibold text-text-primary">
Redefinir senha
</h1>
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
Nova senha
</label>
<input
id="newPassword"
type="password"
placeholder="••••••••"
{...register('newPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.newPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.newPassword.message}
</p>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="mb-1 block text-small text-text-secondary"
>
Confirmar nova senha
</label>
<input
id="confirmPassword"
type="password"
placeholder="••••••••"
{...register('confirmPassword')}
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
/>
{errors.confirmPassword && (
<p className="mt-1 text-small text-red-400" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
{apiError && (
<div role="alert">
<p className="text-small text-red-400">{apiError}</p>
{isExpired && (
<Link
to="/esqueci-senha"
className="mt-1 block text-small text-isis-blue hover:underline"
>
Solicitar novo link
</Link>
)}
</div>
)}
<Button type="submit" loading={isSubmitting} className="w-full">
{isSubmitting ? 'Redefinindo...' : 'Redefinir senha'}
</Button>
</form>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ChangePasswordPage } from '../ChangePasswordPage';
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
changePassword: vi.fn(),
},
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockChangePassword = vi.mocked(authService.changePassword);
function renderPage(userOverrides: Partial<AuthContextData['user']> = {}) {
const updateUser = vi.fn();
const user: AuthContextData['user'] = {
id: '1',
name: 'Test',
email: 'test@test.com',
role: 'ADMIN',
mustChangePassword: false,
...userOverrides,
};
const authValue: AuthContextData = {
user,
token: 'token',
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateUser,
};
return {
updateUser,
...render(
<MemoryRouter>
<AuthContext.Provider value={authValue}>
<ChangePasswordPage />
</AuthContext.Provider>
</MemoryRouter>,
),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockNavigate.mockReset();
});
describe('ChangePasswordPage', () => {
it('shows "Primeiro acesso" context when mustChangePassword=true', () => {
renderPage({ mustChangePassword: true });
expect(screen.getByText('Primeiro acesso — defina sua senha')).toBeInTheDocument();
expect(screen.getByText(/bem-vindo/i)).toBeInTheDocument();
});
it('shows "Trocar senha" context when mustChangePassword=false', () => {
renderPage({ mustChangePassword: false });
expect(screen.getByText('Trocar senha')).toBeInTheDocument();
});
it('shows password complexity error for short password', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Senha atual'), 'current');
await user.type(screen.getByLabelText('Nova senha'), 'short');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
});
});
it('shows complexity error for missing uppercase', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Senha atual'), 'current');
await user.type(screen.getByLabelText('Nova senha'), 'nouppercase1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'nouppercase1');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
).toBeInTheDocument();
});
});
it('renders PasswordStrengthIndicator', () => {
renderPage();
expect(screen.getByRole('progressbar', { name: 'Força da senha' })).toBeInTheDocument();
});
it('updates PasswordStrengthIndicator as user types', async () => {
const user = userEvent.setup();
renderPage();
const newPasswordInput = screen.getByLabelText('Nova senha');
const progressBar = screen.getByRole('progressbar', { name: 'Força da senha' });
expect(progressBar).toHaveAttribute('aria-valuenow', '0');
await user.type(newPasswordInput, 'ValidPass1');
await waitFor(() => {
expect(progressBar).toHaveAttribute('aria-valuenow', '4');
});
});
it('calls updateUser with mustChangePassword=false on success', async () => {
const user = userEvent.setup();
mockChangePassword.mockResolvedValue({ message: 'ok' });
const { updateUser } = renderPage({ mustChangePassword: true });
await user.type(screen.getByLabelText('Senha temporária'), 'temp123');
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
await waitFor(() => {
expect(updateUser).toHaveBeenCalledWith({ mustChangePassword: false });
});
});
});

View File

@@ -0,0 +1,89 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { ForgotPasswordPage } from '../ForgotPasswordPage';
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
forgotPassword: vi.fn(),
},
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockForgotPassword = vi.mocked(authService.forgotPassword);
function renderPage() {
return render(
<MemoryRouter>
<ForgotPasswordPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ForgotPasswordPage', () => {
it('renders email input and submit button', () => {
renderPage();
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Enviar link' })).toBeInTheDocument();
});
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'invalido');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
mockForgotPassword.mockImplementation(() => new Promise(() => {}));
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Enviando...' })).toBeDisabled();
});
});
it('hides form and shows confirmation message on success', async () => {
const user = userEvent.setup();
mockForgotPassword.mockResolvedValue({ message: 'link enviado' });
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.queryByLabelText('E-mail')).not.toBeInTheDocument();
expect(
screen.getByText(/se o e-mail informado estiver cadastrado/i),
).toBeInTheDocument();
});
});
it('shows error message on network failure', async () => {
const user = userEvent.setup();
mockForgotPassword.mockRejectedValue(new Error('Network error'));
renderPage();
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
await waitFor(() => {
expect(screen.getByText('Erro ao processar solicitação. Tente novamente.')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { LoginPage } from '../LoginPage';
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
function renderLoginPage(authOverrides: Partial<AuthContextData> = {}) {
const authValue: AuthContextData = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateUser: vi.fn(),
...authOverrides,
};
return {
authValue,
...render(
<MemoryRouter>
<AuthContext.Provider value={authValue}>
<LoginPage />
</AuthContext.Provider>
</MemoryRouter>,
),
};
}
beforeEach(() => {
mockNavigate.mockReset();
});
describe('LoginPage', () => {
it('renders form with email, password and submit button', () => {
renderLoginPage();
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
expect(screen.getByLabelText('Senha')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Entrar' })).toBeInTheDocument();
});
it('shows validation errors when submitting empty form', async () => {
const user = userEvent.setup();
renderLoginPage();
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail é obrigatório')).toBeInTheDocument();
});
expect(screen.getByText('Senha é obrigatória')).toBeInTheDocument();
});
it('shows email validation error for invalid email', async () => {
const user = userEvent.setup();
renderLoginPage();
// jsdom doesn't enforce type="email" validation, so RHF + Zod validates
await user.type(screen.getByLabelText('E-mail'), 'invalido');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
const loginMock = vi.fn(() => new Promise<void>(() => {})); // never resolves
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Entrando...' })).toBeDisabled();
});
});
it('shows API error message on 401', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 401 },
};
const loginMock = vi.fn().mockRejectedValue(axiosError);
// Mock axios.isAxiosError to recognize our error
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('E-mail ou senha incorretos')).toBeInTheDocument();
});
});
it('shows API error message on 403 (inactive user)', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 403 },
};
const loginMock = vi.fn().mockRejectedValue(axiosError);
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(screen.getByText('Usuário inativo')).toBeInTheDocument();
});
});
it('navigates to / on successful login', async () => {
const user = userEvent.setup();
const loginMock = vi.fn().mockResolvedValue(undefined);
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(loginMock).toHaveBeenCalledWith('admin@iasis.com.br', 'password');
});
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
it('stores token in localStorage after successful login', async () => {
const user = userEvent.setup();
const loginMock = vi.fn().mockImplementation(async () => {
// Simulate what AuthProvider.login does
localStorage.setItem('@iasis:token', 'test-token');
});
renderLoginPage({ login: loginMock });
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
await user.type(screen.getByLabelText('Senha'), 'password');
await user.click(screen.getByRole('button', { name: 'Entrar' }));
await waitFor(() => {
expect(localStorage.getItem('@iasis:token')).toBe('test-token');
});
});
});

View File

@@ -0,0 +1,103 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ResetPasswordPage } from '../ResetPasswordPage';
vi.mock('../../../modules/auth/auth.service', () => ({
authService: {
resetPassword: vi.fn(),
},
}));
const { authService } = await import('../../../modules/auth/auth.service');
const mockResetPassword = vi.mocked(authService.resetPassword);
function renderPage(token = 'test-token') {
return render(
<MemoryRouter initialEntries={[`/redefinir-senha/${token}`]}>
<Routes>
<Route path="/redefinir-senha/:token" element={<ResetPasswordPage />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ResetPasswordPage', () => {
it('renders password fields and submit button', () => {
renderPage();
expect(screen.getByLabelText('Nova senha')).toBeInTheDocument();
expect(screen.getByLabelText('Confirmar nova senha')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Redefinir senha' })).toBeInTheDocument();
});
it('shows complexity error when password is too short', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'short');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
});
});
it('shows uppercase error when password has no uppercase letter', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'validpass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'validpass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
).toBeInTheDocument();
});
});
it('shows success message on 200 response', async () => {
const user = userEvent.setup();
mockResetPassword.mockResolvedValue({ message: 'Senha redefinida com sucesso.' });
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText(/senha redefinida com sucesso/i)).toBeInTheDocument();
});
});
it('shows expired link message and link to /esqueci-senha on 400 with expired message', async () => {
const user = userEvent.setup();
const axiosError = {
isAxiosError: true,
response: { status: 400, data: { message: 'Token de redefinição expirado' } },
};
mockResetPassword.mockRejectedValue(axiosError);
const axios = await import('axios');
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
renderPage();
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
await waitFor(() => {
expect(screen.getByText('O link de redefinição expirou.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Solicitar novo link' })).toBeInTheDocument();
});
});
});

View File

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createContract } from '../../services/contracts.service';
import { useClient } from '../../hooks/useClients';
const createContractSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type CreateContractFormData = z.infer<typeof createContractSchema>;
export function ClientContractCreatePage() {
const { id: clientId } = useParams<{ id: string }>();
const { data: client } = useClient(clientId!);
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '...' });
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateContractFormData>({
resolver: zodResolver(createContractSchema),
});
async function onSubmit(data: CreateContractFormData) {
setApiError(null);
try {
await createContract({
name: data.name,
clientId: clientId!,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'contracts'] });
showToast('Contrato criado com sucesso', 'success');
navigate(`/clientes/${clientId}?tab=contracts`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar contrato. Tente novamente.');
} else {
setApiError('Erro ao criar contrato. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Contrato" breadcrumbs={breadcrumbs} />
<FormCard title={`Novo Contrato — ${client?.name ?? '...'}`}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do contrato"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Código"
placeholder="Código do contrato"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do contrato"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
type="button"
onClick={() => navigate(`/clientes/${clientId}?tab=contracts`)}
>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,273 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, CurrencyInput, parseCurrencyToNumber } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createContractItem } from '../../services/contract-items.service';
import { ContractItemType } from '../../types/contract-item.types';
import { CONTRACT_ITEM_TYPE_LABELS } from '../../constants/contract-item-type';
import { useClient } from '../../hooks/useClients';
const positiveNumberString = (label: string) =>
z
.string()
.min(1, `${label} é obrigatório`)
.refine(
(val) => {
const num = Number(val);
return !isNaN(num) && num > 0;
},
{ message: `${label} deve ser um número positivo` },
);
const positiveCurrencyString = (label: string) =>
z
.string()
.min(1, `${label} é obrigatório`)
.refine(
(val) => {
const num = parseCurrencyToNumber(val);
return num !== undefined && num > 0;
},
{ message: `${label} deve ser maior que zero` },
);
const ustSchema = z.object({
itemType: z.literal(ContractItemType.UST),
code: z.string().min(1, 'Código é obrigatório'),
name: z.string().min(1, 'Nome é obrigatório'),
description: z.string().optional(),
totalUst: positiveNumberString('Total UST'),
ustValue: z.string().optional(),
timeboxDescoberta: z.string().optional(),
timeboxDesign: z.string().optional(),
timeboxArquitetura: z.string().optional(),
timeboxConstrucao: z.string().optional(),
});
const saasSchema = z.object({
itemType: z.literal(ContractItemType.SAAS_LICENSE),
code: z.string().min(1, 'Código é obrigatório'),
name: z.string().min(1, 'Nome é obrigatório'),
description: z.string().optional(),
totalUst: positiveNumberString('Quantidade'),
ustValue: positiveCurrencyString('Valor unitário'),
});
const createContractItemSchema = z.discriminatedUnion('itemType', [ustSchema, saasSchema]);
type CreateContractItemFormData = z.infer<typeof createContractItemSchema>;
const itemTypeOptions = [
{ value: ContractItemType.UST, label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.UST] },
{
value: ContractItemType.SAAS_LICENSE,
label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.SAAS_LICENSE],
},
];
export function ClientContractItemCreatePage() {
const { id: clientId } = useParams<{ id: string }>();
const { data: client } = useClient(clientId!);
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '...' });
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<CreateContractItemFormData>({
resolver: zodResolver(createContractItemSchema),
defaultValues: { itemType: ContractItemType.UST },
});
const itemType = watch('itemType');
async function onSubmit(data: CreateContractItemFormData) {
setApiError(null);
try {
const basePayload = {
code: data.code,
name: data.name,
description: data.description || undefined,
itemType: data.itemType,
totalUst: Number(data.totalUst),
};
const payload =
data.itemType === ContractItemType.UST
? {
...basePayload,
ustValue: parseCurrencyToNumber(data.ustValue ?? ''),
timeboxDescoberta: data.timeboxDescoberta
? Number(data.timeboxDescoberta)
: undefined,
timeboxDesign: data.timeboxDesign ? Number(data.timeboxDesign) : undefined,
timeboxArquitetura: data.timeboxArquitetura
? Number(data.timeboxArquitetura)
: undefined,
timeboxConstrucao: data.timeboxConstrucao
? Number(data.timeboxConstrucao)
: undefined,
}
: {
...basePayload,
ustValue: parseCurrencyToNumber(data.ustValue),
};
await createContractItem(clientId!, payload);
await queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'contract-items'] });
showToast('Item de contrato criado com sucesso', 'success');
navigate(`/clientes/${clientId}?tab=contract-items`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(
err.response?.data?.message ?? 'Erro ao criar item de contrato. Tente novamente.',
);
} else {
setApiError('Erro ao criar item de contrato. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Item de Contrato" breadcrumbs={breadcrumbs} />
<FormCard title={`Novo Item de Contrato — ${client?.name ?? '...'}`}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Select
label="Tipo do item"
options={itemTypeOptions}
error={errors.itemType?.message}
{...register('itemType')}
/>
<Input
label="Código"
placeholder="Código do item"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Nome"
placeholder="Nome do item"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Descrição"
placeholder="Descrição do item (opcional)"
error={errors.description?.message}
{...register('description')}
/>
{itemType === ContractItemType.UST ? (
<>
<Input
label="Total UST"
type="number"
placeholder="Quantidade total de USTs"
error={errors.totalUst?.message}
{...register('totalUst')}
/>
<div className="border-t border-border pt-4">
<h3 className="text-body font-medium text-primary mb-4">
Valoração e Carga Horária Semanal
</h3>
<CurrencyInput
label="Valor da UST (R$)"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
<div className="grid grid-cols-2 gap-4 mt-4">
<Input
label="Descoberta (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxDescoberta' in errors ? errors.timeboxDescoberta?.message : undefined
}
{...register('timeboxDescoberta')}
/>
<Input
label="Design (h/semana)"
type="number"
placeholder="Horas"
error={'timeboxDesign' in errors ? errors.timeboxDesign?.message : undefined}
{...register('timeboxDesign')}
/>
<Input
label="Arquitetura (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxArquitetura' in errors
? errors.timeboxArquitetura?.message
: undefined
}
{...register('timeboxArquitetura')}
/>
<Input
label="Construção (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxConstrucao' in errors ? errors.timeboxConstrucao?.message : undefined
}
{...register('timeboxConstrucao')}
/>
</div>
</div>
</>
) : (
<>
<Input
label="Quantidade"
type="number"
placeholder="Quantidade de licenças"
error={errors.totalUst?.message}
{...register('totalUst')}
/>
<CurrencyInput
label="Valor unitário (R$)"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
</>
)}
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
type="button"
onClick={() => navigate(`/clientes/${clientId}?tab=contract-items`)}
>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,328 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import {
Button,
Input,
Select,
CurrencyInput,
parseCurrencyToNumber,
numberToCurrencyString,
} from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { updateContractItem } from '../../services/contract-items.service';
import { ContractItemType } from '../../types/contract-item.types';
import { CONTRACT_ITEM_TYPE_LABELS } from '../../constants/contract-item-type';
import { useClient } from '../../hooks/useClients';
import { useContractItem } from '../../hooks/useContractItems';
const positiveNumberString = (label: string) =>
z
.string()
.min(1, `${label} é obrigatório`)
.refine(
(val) => {
const num = Number(val);
return !isNaN(num) && num > 0;
},
{ message: `${label} deve ser um número positivo` },
);
const positiveCurrencyString = (label: string) =>
z
.string()
.min(1, `${label} é obrigatório`)
.refine(
(val) => {
const num = parseCurrencyToNumber(val);
return num !== undefined && num > 0;
},
{ message: `${label} deve ser maior que zero` },
);
const ustSchema = z.object({
itemType: z.literal(ContractItemType.UST),
code: z.string().min(1, 'Código é obrigatório'),
name: z.string().min(1, 'Nome é obrigatório'),
description: z.string().optional(),
totalUst: positiveNumberString('Total UST'),
ustValue: z.string().optional(),
timeboxDescoberta: z.string().optional(),
timeboxDesign: z.string().optional(),
timeboxArquitetura: z.string().optional(),
timeboxConstrucao: z.string().optional(),
});
const saasSchema = z.object({
itemType: z.literal(ContractItemType.SAAS_LICENSE),
code: z.string().min(1, 'Código é obrigatório'),
name: z.string().min(1, 'Nome é obrigatório'),
description: z.string().optional(),
totalUst: positiveNumberString('Quantidade'),
ustValue: positiveCurrencyString('Valor unitário'),
});
const editContractItemSchema = z.discriminatedUnion('itemType', [ustSchema, saasSchema]);
type EditContractItemFormData = z.infer<typeof editContractItemSchema>;
const itemTypeOptions = [
{ value: ContractItemType.UST, label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.UST] },
{
value: ContractItemType.SAAS_LICENSE,
label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.SAAS_LICENSE],
},
];
export function ClientContractItemEditPage() {
const { id: clientId, itemId } = useParams<{ id: string; itemId: string }>();
const { data: client } = useClient(clientId!);
const { data: contractItem, isLoading: isLoadingItem } = useContractItem(clientId!, itemId!);
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '...' });
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<EditContractItemFormData>({
resolver: zodResolver(editContractItemSchema),
defaultValues: { itemType: ContractItemType.UST },
});
const itemType = watch('itemType');
useEffect(() => {
if (!contractItem) return;
if (contractItem.itemType === ContractItemType.SAAS_LICENSE) {
reset({
itemType: ContractItemType.SAAS_LICENSE,
code: contractItem.code,
name: contractItem.name,
description: contractItem.description ?? '',
totalUst: String(Number(contractItem.totalUst)),
ustValue: numberToCurrencyString(contractItem.ustValue),
});
} else {
reset({
itemType: ContractItemType.UST,
code: contractItem.code,
name: contractItem.name,
description: contractItem.description ?? '',
totalUst: String(Number(contractItem.totalUst)),
ustValue: numberToCurrencyString(contractItem.ustValue),
timeboxDescoberta: contractItem.timeboxDescoberta
? String(Number(contractItem.timeboxDescoberta))
: '',
timeboxDesign: contractItem.timeboxDesign ? String(Number(contractItem.timeboxDesign)) : '',
timeboxArquitetura: contractItem.timeboxArquitetura
? String(Number(contractItem.timeboxArquitetura))
: '',
timeboxConstrucao: contractItem.timeboxConstrucao
? String(Number(contractItem.timeboxConstrucao))
: '',
});
}
}, [contractItem, reset]);
async function onSubmit(data: EditContractItemFormData) {
setApiError(null);
try {
const basePayload = {
code: data.code,
name: data.name,
description: data.description || undefined,
totalUst: Number(data.totalUst),
};
const payload =
data.itemType === ContractItemType.UST
? {
...basePayload,
ustValue: parseCurrencyToNumber(data.ustValue ?? ''),
timeboxDescoberta: data.timeboxDescoberta
? Number(data.timeboxDescoberta)
: undefined,
timeboxDesign: data.timeboxDesign ? Number(data.timeboxDesign) : undefined,
timeboxArquitetura: data.timeboxArquitetura
? Number(data.timeboxArquitetura)
: undefined,
timeboxConstrucao: data.timeboxConstrucao
? Number(data.timeboxConstrucao)
: undefined,
}
: {
...basePayload,
ustValue: parseCurrencyToNumber(data.ustValue),
};
await updateContractItem(clientId!, itemId!, payload);
await queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'contract-items'] });
showToast('Item de contrato atualizado com sucesso', 'success');
navigate(`/clientes/${clientId}?tab=contract-items`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(
err.response?.data?.message ?? 'Erro ao atualizar item de contrato. Tente novamente.',
);
} else {
setApiError('Erro ao atualizar item de contrato. Tente novamente.');
}
}
}
if (isLoadingItem) {
return (
<PageContainer>
<div className="animate-pulse space-y-4">
<div className="h-6 bg-hover rounded w-1/3" />
<div className="h-64 bg-hover rounded" />
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Item de Contrato" breadcrumbs={breadcrumbs} />
<FormCard title={`Editar Item de Contrato — ${client?.name ?? '...'}`}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Select
label="Tipo do item"
options={itemTypeOptions}
error={errors.itemType?.message}
disabled
{...register('itemType')}
/>
<Input
label="Código"
placeholder="Código do item"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Nome"
placeholder="Nome do item"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Descrição"
placeholder="Descrição do item (opcional)"
error={errors.description?.message}
{...register('description')}
/>
{itemType === ContractItemType.UST ? (
<>
<Input
label="Total UST"
type="number"
placeholder="Quantidade total de USTs"
error={errors.totalUst?.message}
{...register('totalUst')}
/>
<div className="border-t border-border pt-4">
<h3 className="text-body font-medium text-primary mb-4">
Valoração e Carga Horária Semanal
</h3>
<CurrencyInput
label="Valor da UST (R$)"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
<div className="grid grid-cols-2 gap-4 mt-4">
<Input
label="Descoberta (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxDescoberta' in errors ? errors.timeboxDescoberta?.message : undefined
}
{...register('timeboxDescoberta')}
/>
<Input
label="Design (h/semana)"
type="number"
placeholder="Horas"
error={'timeboxDesign' in errors ? errors.timeboxDesign?.message : undefined}
{...register('timeboxDesign')}
/>
<Input
label="Arquitetura (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxArquitetura' in errors
? errors.timeboxArquitetura?.message
: undefined
}
{...register('timeboxArquitetura')}
/>
<Input
label="Construção (h/semana)"
type="number"
placeholder="Horas"
error={
'timeboxConstrucao' in errors ? errors.timeboxConstrucao?.message : undefined
}
{...register('timeboxConstrucao')}
/>
</div>
</div>
</>
) : (
<>
<Input
label="Quantidade"
type="number"
placeholder="Quantidade de licenças"
error={errors.totalUst?.message}
{...register('totalUst')}
/>
<CurrencyInput
label="Valor unitário (R$)"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
</>
)}
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
type="button"
onClick={() => navigate(`/clientes/${clientId}?tab=contract-items`)}
>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, CpfCnpjInput, PhoneInput } from '../../components/ui';
import { isValidCpfCnpj } from '../../components/ui/CpfCnpjInput';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createClient } from '../../services/clients.service';
const createClientSchema = z.object({
name: z.string().min(1, 'Nome é obrigatório'),
document: z
.string()
.optional()
.refine((val) => !val || isValidCpfCnpj(val), { message: 'CPF ou CNPJ inválido' }),
email: z.string().email('E-mail inválido').optional().or(z.literal('')),
phone: z.string().optional(),
contactName: z.string().optional(),
description: z.string().optional(),
});
type CreateClientFormData = z.infer<typeof createClientSchema>;
export function ClientCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateClientFormData>({
resolver: zodResolver(createClientSchema),
});
async function onSubmit(data: CreateClientFormData) {
setApiError(null);
try {
await createClient({
name: data.name,
document: data.document || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
contactName: data.contactName || undefined,
description: data.description || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['clients'] });
showToast('Cliente criado com sucesso', 'success');
navigate('/clientes');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar cliente. Tente novamente.');
} else {
setApiError('Erro ao criar cliente. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Cliente" breadcrumbs={breadcrumbs} />
<FormCard title="Novo Cliente">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do cliente"
error={errors.name?.message}
{...register('name')}
/>
<CpfCnpjInput
label="Documento"
error={errors.document?.message}
{...register('document')}
/>
<Input
label="E-mail"
type="email"
placeholder="cliente@email.com"
error={errors.email?.message}
{...register('email')}
/>
<PhoneInput label="Telefone" error={errors.phone?.message} {...register('phone')} />
<Input
label="Nome do Contato"
placeholder="Pessoa de contato"
error={errors.contactName?.message}
{...register('contactName')}
/>
<Input
label="Descrição"
placeholder="Descrição do cliente"
error={errors.description?.message}
{...register('description')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/clientes')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,40 @@
import { Drawer, DetailField, Badge } from '../../components/ui';
import type { ClientListItem } from '../../types/client.types';
interface ClientDetailDrawerProps {
open: boolean;
client: ClientListItem | null;
onClose: () => void;
}
function formatDate(dateStr: string): string {
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function ClientDetailDrawer({ open, client, onClose }: ClientDetailDrawerProps) {
return (
<Drawer open={open} title="Detalhes do Cliente" onClose={onClose}>
{client && (
<div className="flex flex-col gap-5">
<DetailField label="Nome" value={client.name} />
<DetailField label="Documento" value={client.document} />
<DetailField label="E-mail" value={client.email} />
<DetailField label="Telefone" value={client.phone} />
<DetailField label="Nome do Contato" value={client.contactName} />
<DetailField label="Descrição" value={client.description} />
<DetailField
label="Status"
value={
<Badge variant={client.isActive ? 'success' : 'danger'}>
{client.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
<DetailField label="Criado em" value={formatDate(client.createdAt)} />
<DetailField label="Atualizado em" value={formatDate(client.updatedAt)} />
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,161 @@
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Pencil, FileSignature, FolderKanban, Package, Users, LayoutTemplate } from 'lucide-react';
import { PageContainer } from '../../components/layout/PageContainer';
import { PageHeader } from '../../components/layout/PageHeader';
import { Badge, Button, DetailField } from '../../components/ui';
import { Tabs } from '../../components/ui/Tabs';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useReadOnly } from '../../hooks/useReadOnly';
import { useClient } from '../../hooks/useClients';
import { useClientContracts } from '../../hooks/useContracts';
import { useClientContractItems } from '../../hooks/useContractItems';
import { useClientProjects } from '../../hooks/useProjects';
import { useClientProfiles } from '../../hooks/useClientProfiles';
import { useAllocationTemplates } from '../../hooks/useAllocationTemplates';
import { ClientContractsTab } from './components/ClientContractsTab';
import { ClientContractItemsTab } from './components/ClientContractItemsTab';
import { ClientProjectsTab } from './components/ClientProjectsTab';
import { ClientProfilesTab } from './components/ClientProfilesTab';
import { ClientAllocationTemplatesTab } from './components/ClientAllocationTemplatesTab';
export function ClientDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { data: client, isLoading, isError } = useClient(id!);
const { isReadOnly } = useReadOnly();
const activeTab = searchParams.get('tab') || 'contracts';
const contractsQuery = useClientContracts(id!, { page: 1, limit: 1 });
const contractItemsQuery = useClientContractItems(id!, { page: 1, limit: 1 });
const projectsQuery = useClientProjects(id!, { page: 1, limit: 1 });
const profilesQuery = useClientProfiles(id!, { page: 1, limit: 1 });
const templatesQuery = useAllocationTemplates(id!, { page: 1, limit: 1 });
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '...' });
function handleTabChange(tabId: string) {
setSearchParams({ tab: tabId }, { replace: true });
}
if (isLoading) {
return (
<PageContainer>
<div className="animate-pulse space-y-4">
<div className="h-6 bg-hover rounded w-1/3" />
<div className="h-32 bg-hover rounded" />
<div className="h-64 bg-hover rounded" />
</div>
</PageContainer>
);
}
if (isError || !client) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-muted text-lg">Cliente não encontrado</p>
<Button variant="ghost" className="mt-4" onClick={() => navigate('/clientes')}>
Voltar para Clientes
</Button>
</div>
</PageContainer>
);
}
const tabs = [
{
id: 'contracts',
label: 'Contratos',
icon: <FileSignature size={16} />,
count: contractsQuery.data?.total,
},
{
id: 'contract-items',
label: 'Itens de Contrato',
icon: <Package size={16} />,
count: contractItemsQuery.data?.total,
},
{
id: 'projects',
label: 'Projetos',
icon: <FolderKanban size={16} />,
count: projectsQuery.data?.total,
},
{
id: 'profiles',
label: 'Perfis',
icon: <Users size={16} />,
count: profilesQuery.data?.total,
},
{
id: 'allocation-templates',
label: 'Templates de Alocação',
icon: <LayoutTemplate size={16} />,
count: templatesQuery.data?.total,
},
];
return (
<PageContainer>
<PageHeader
title={client.name}
breadcrumbs={breadcrumbs}
actions={
!isReadOnly ? (
<Button variant="secondary" onClick={() => navigate(`/clientes/${id}/editar`)}>
<Pencil size={16} />
Editar
</Button>
) : undefined
}
/>
<div className="bg-card border border-border rounded-lg p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<DetailField label="Nome" value={client.name} />
<DetailField label="Documento" value={client.document} />
<DetailField label="E-mail" value={client.email} />
<DetailField label="Telefone" value={client.phone} />
<DetailField label="Nome do Contato" value={client.contactName} />
<DetailField
label="Status"
value={
<Badge variant={client.isActive ? 'success' : 'danger'}>
{client.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
</div>
{client.description && (
<div className="mt-5">
<DetailField label="Descrição" value={client.description} />
</div>
)}
</div>
<Tabs tabs={tabs} activeTab={activeTab} onChange={handleTabChange}>
{(activeTabId) => (
<div className="mt-6">
{activeTabId === 'contracts' && (
<ClientContractsTab clientId={id!} readOnly={isReadOnly} />
)}
{activeTabId === 'contract-items' && (
<ClientContractItemsTab clientId={id!} readOnly={isReadOnly} />
)}
{activeTabId === 'projects' && (
<ClientProjectsTab clientId={id!} readOnly={isReadOnly} />
)}
{activeTabId === 'profiles' && (
<ClientProfilesTab clientId={id!} readOnly={isReadOnly} />
)}
{activeTabId === 'allocation-templates' && (
<ClientAllocationTemplatesTab clientId={id!} readOnly={isReadOnly} />
)}
</div>
)}
</Tabs>
</PageContainer>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, CpfCnpjInput, PhoneInput } from '../../components/ui';
import { isValidCpfCnpj } from '../../components/ui/CpfCnpjInput';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useClient } from '../../hooks/useClients';
import { updateClient } from '../../services/clients.service';
const editClientSchema = z.object({
name: z.string().min(1, 'Nome é obrigatório'),
document: z
.string()
.optional()
.refine((val) => !val || isValidCpfCnpj(val), { message: 'CPF ou CNPJ inválido' }),
email: z.string().email('E-mail inválido').optional().or(z.literal('')),
phone: z.string().optional(),
contactName: z.string().optional(),
description: z.string().optional(),
isActive: z.boolean(),
});
type EditClientFormData = z.infer<typeof editClientSchema>;
export function ClientEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: client, isLoading: isLoadingClient, isError } = useClient(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '' });
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<EditClientFormData>({
resolver: zodResolver(editClientSchema),
});
const isActive = watch('isActive');
useEffect(() => {
if (client) {
reset({
name: client.name,
document: client.document ?? '',
email: client.email ?? '',
phone: client.phone ?? '',
contactName: client.contactName ?? '',
description: client.description ?? '',
isActive: client.isActive,
});
}
}, [client, reset]);
async function onSubmit(data: EditClientFormData) {
if (!id) return;
setApiError(null);
try {
await updateClient(id, {
name: data.name,
document: data.document || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
contactName: data.contactName || undefined,
description: data.description || undefined,
isActive: data.isActive,
});
await queryClient.invalidateQueries({ queryKey: ['clients'] });
showToast('Cliente atualizado com sucesso', 'success');
navigate('/clientes');
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 404) {
setApiError('Cliente não encontrado');
} else {
setApiError(err.response?.data?.message ?? 'Erro ao atualizar cliente. Tente novamente.');
}
} else {
setApiError('Erro ao atualizar cliente. Tente novamente.');
}
}
}
if (isLoadingClient) {
return (
<PageContainer>
<PageHeader title="Editar Cliente" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !client) {
return (
<PageContainer>
<PageHeader title="Editar Cliente" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Cliente não encontrado</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Cliente" breadcrumbs={breadcrumbs} />
<FormCard title="Editar Cliente">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do cliente"
error={errors.name?.message}
{...register('name')}
/>
<CpfCnpjInput
label="Documento"
error={errors.document?.message}
{...register('document')}
/>
<Input
label="E-mail"
type="email"
placeholder="cliente@email.com"
error={errors.email?.message}
{...register('email')}
/>
<PhoneInput label="Telefone" error={errors.phone?.message} {...register('phone')} />
<Input
label="Nome do Contato"
placeholder="Pessoa de contato"
error={errors.contactName?.message}
{...register('contactName')}
/>
<Input
label="Descrição"
placeholder="Descrição do cliente"
error={errors.description?.message}
{...register('description')}
/>
<div className="flex items-center justify-between rounded border px-4 py-3">
<span className="text-body text-primary">Status</span>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={isActive ?? true}
onChange={(e) => setValue('isActive', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-danger/40 transition-colors peer-checked:bg-success peer-focus:ring-2 peer-focus:ring-isis-blue after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full" />
<span className="ml-2 text-small text-text-secondary">
{isActive ? 'Ativo' : 'Inativo'}
</span>
</label>
</div>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/clientes')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createProject } from '../../services/projects.service';
import { useClient } from '../../hooks/useClients';
import { useClientActiveContracts } from '../../hooks/useContracts';
const createProjectSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
contractId: z.string().min(1, 'Contrato é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type CreateProjectFormData = z.infer<typeof createProjectSchema>;
export function ClientProjectCreatePage() {
const { id: clientId } = useParams<{ id: string }>();
const { data: client } = useClient(clientId!);
const breadcrumbs = useBreadcrumbs({ ':name': client?.name ?? '...' });
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: activeContracts } = useClientActiveContracts(clientId!);
const contractOptions = (activeContracts ?? []).map((c) => ({
value: c.id,
label: c.name,
}));
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateProjectFormData>({
resolver: zodResolver(createProjectSchema),
});
async function onSubmit(data: CreateProjectFormData) {
setApiError(null);
try {
await createProject({
name: data.name,
contractId: data.contractId,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] });
showToast('Projeto criado com sucesso', 'success');
navigate(`/clientes/${clientId}?tab=projects`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar projeto. Tente novamente.');
} else {
setApiError('Erro ao criar projeto. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Projeto" breadcrumbs={breadcrumbs} />
<FormCard title={`Novo Projeto — ${client?.name ?? '...'}`}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do projeto"
error={errors.name?.message}
{...register('name')}
/>
<Select
label="Contrato"
options={contractOptions}
placeholder="Selecione um contrato"
error={errors.contractId?.message}
{...register('contractId')}
/>
<Input
label="Código"
placeholder="Código do projeto"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do projeto"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
type="button"
onClick={() => navigate(`/clientes/${clientId}?tab=projects`)}
>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,246 @@
import { useState, useMemo } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useReadOnly } from '../../hooks/useReadOnly';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../components/ui';
import { useClients } from '../../hooks/useClients';
import { useToast } from '../../components/ui/Toast';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { updateClient } from '../../services/clients.service';
import type { ClientsFilters, ClientListItem } from '../../types/client.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
export function ClientsPage() {
const { isReadOnly, clientId: myClientId } = useReadOnly();
if (isReadOnly && myClientId) {
return <Navigate to={`/clientes/${myClientId}`} replace />;
}
return <ClientsPageContent />;
}
function ClientsPageContent() {
const breadcrumbs = useBreadcrumbs();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [clientToToggle, setClientToToggle] = useState<ClientListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (client: ClientListItem) => updateClient(client.id, { isActive: !client.isActive }),
onSuccess: (_data, client) => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
showToast(
client.isActive ? 'Cliente inativado com sucesso' : 'Cliente ativado com sucesso',
'success',
);
setClientToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do cliente', 'error');
setClientToToggle(null);
},
});
const filters: ClientsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useClients(filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (client: ClientListItem) => <span className="text-primary">{client.name}</span>,
},
{
key: 'document',
header: 'Documento',
render: (client: ClientListItem) => (
<span className="text-text-secondary">{client.document ?? '—'}</span>
),
},
{
key: 'email',
header: 'E-mail',
render: (client: ClientListItem) => (
<span className="text-text-secondary">{client.email ?? '—'}</span>
),
},
{
key: 'phone',
header: 'Telefone',
render: (client: ClientListItem) => (
<span className="text-text-secondary">{client.phone ?? '—'}</span>
),
},
{
key: 'contactName',
header: 'Contato',
render: (client: ClientListItem) => (
<span className="text-text-secondary">{client.contactName ?? '—'}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (client: ClientListItem) => (
<Badge variant={client.isActive ? 'success' : 'danger'}>
{client.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (client: ClientListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/clientes/${client.id}`)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/clientes/${client.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
<Tooltip content={client.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setClientToToggle(client)}
className={
client.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={client.isActive ? 'Inativar' : 'Ativar'}
>
{client.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
</div>
),
},
],
[navigate],
);
return (
<PageContainer>
<PageHeader
title="Clientes"
breadcrumbs={breadcrumbs}
actions={
<Button onClick={() => navigate('/clientes/novo')} icon={<Plus size={16} />}>
Novo Cliente
</Button>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
</div>
<DataTable<ClientListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum cliente encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="cliente"
itemLabelPlural="clientes"
onPageChange={setPage}
/>
<ConfirmDialog
open={!!clientToToggle}
title={clientToToggle?.isActive ? 'Inativar cliente' : 'Ativar cliente'}
message={
clientToToggle?.isActive
? `Tem certeza que deseja inativar o cliente "${clientToToggle?.name}"?`
: `Tem certeza que deseja ativar o cliente "${clientToToggle?.name}"?`
}
confirmLabel={clientToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={clientToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (clientToToggle) toggleStatusMutation.mutate(clientToToggle);
}}
onCancel={() => setClientToToggle(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ClientContractItemCreatePage } from '../ClientContractItemCreatePage';
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ id: 'cli-1' }),
};
});
vi.mock('../../../hooks/useClients', () => ({
useClient: () => ({ data: { id: 'cli-1', name: 'Cliente A' } }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [{ label: 'Dashboard', to: '/' }],
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../services/contract-items.service', () => ({
createContractItem: vi.fn(),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<ClientContractItemCreatePage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('ClientContractItemCreatePage', () => {
it('renderiza select de tipo e branch UST com timeboxes por padrão', () => {
renderPage();
expect(screen.getByText('Tipo do item')).toBeInTheDocument();
expect(screen.getByText('Total UST')).toBeInTheDocument();
expect(screen.getByText('Descoberta (h/semana)')).toBeInTheDocument();
expect(screen.getByText('Design (h/semana)')).toBeInTheDocument();
});
it('alterna para SAAS_LICENSE escondendo timeboxes e mostrando Quantidade + Valor unitário', async () => {
const user = userEvent.setup();
const { container } = renderPage();
const typeSelect = container.querySelector<HTMLSelectElement>('select[name="itemType"]')!;
await user.selectOptions(typeSelect, 'SAAS_LICENSE');
expect(screen.getByText('Quantidade')).toBeInTheDocument();
expect(screen.getByText('Valor unitário (R$)')).toBeInTheDocument();
expect(screen.queryByText('Descoberta (h/semana)')).not.toBeInTheDocument();
expect(screen.queryByText('Design (h/semana)')).not.toBeInTheDocument();
expect(screen.queryByText('Arquitetura (h/semana)')).not.toBeInTheDocument();
expect(screen.queryByText('Construção (h/semana)')).not.toBeInTheDocument();
});
it('Zod bloqueia submit SAAS sem ustValue', async () => {
const user = userEvent.setup();
const { container } = renderPage();
const typeSelect = container.querySelector<HTMLSelectElement>('select[name="itemType"]')!;
await user.selectOptions(typeSelect, 'SAAS_LICENSE');
await user.type(container.querySelector<HTMLInputElement>('input[name="code"]')!, 'IC-1');
await user.type(container.querySelector<HTMLInputElement>('input[name="name"]')!, 'Licença');
await user.type(container.querySelector<HTMLInputElement>('input[name="totalUst"]')!, '10');
await user.click(screen.getByRole('button', { name: /Salvar/i }));
const errorMessages = await screen.findAllByText(/obrigatório/i);
expect(errorMessages.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ClientContractItemEditPage } from '../ClientContractItemEditPage';
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ id: 'cli-1', itemId: 'item-1' }),
};
});
vi.mock('../../../hooks/useClients', () => ({
useClient: () => ({ data: { id: 'cli-1', name: 'Cliente A' } }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [{ label: 'Dashboard', to: '/' }],
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../services/contract-items.service', () => ({
updateContractItem: vi.fn(),
}));
const useContractItemMock = vi.fn();
vi.mock('../../../hooks/useContractItems', () => ({
useContractItem: (...args: unknown[]) => useContractItemMock(...args),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<ClientContractItemEditPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('ClientContractItemEditPage', () => {
it('mantém select de tipo desabilitado em modo edit', () => {
useContractItemMock.mockReturnValue({
data: {
id: 'item-1',
code: 'IC-1',
name: 'Item',
description: '',
itemType: 'UST',
totalUst: 1000,
ustValue: 100,
timeboxDescoberta: null,
timeboxDesign: null,
timeboxArquitetura: null,
timeboxConstrucao: null,
},
isLoading: false,
});
const { container } = renderPage();
const select = container.querySelector<HTMLSelectElement>('select[name="itemType"]')!;
expect(select).toBeDisabled();
});
it('hidrata branch SAAS_LICENSE com Quantidade e Valor unitário sem timeboxes', () => {
useContractItemMock.mockReturnValue({
data: {
id: 'item-1',
code: 'IC-2',
name: 'Licença',
description: '',
itemType: 'SAAS_LICENSE',
totalUst: 50,
ustValue: 200,
},
isLoading: false,
});
renderPage();
expect(screen.getByText('Quantidade')).toBeInTheDocument();
expect(screen.getByText('Valor unitário (R$)')).toBeInTheDocument();
expect(screen.queryByText('Descoberta (h/semana)')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,525 @@
import { useState, useMemo } from 'react';
import { Plus, Eye, Pencil, Trash2, X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
Input,
} from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { Drawer } from '../../../components/ui/Drawer';
import { useAllocationTemplates } from '../../../hooks/useAllocationTemplates';
import { useClientActiveContractItems } from '../../../hooks/useContractItems';
import { useClientActiveProfiles } from '../../../hooks/useClientProfiles';
import {
getAllocationTemplate,
createAllocationTemplate,
updateAllocationTemplate,
deleteAllocationTemplate,
} from '../../../services/allocation-templates.service';
import {
SPRINT_TYPE_LABELS,
SPRINT_TYPE_VARIANTS,
SPRINT_TYPE_OPTIONS,
} from '../../../constants/sprint';
import type { SprintType } from '../../../types/sprint.types';
import type {
AllocationTemplateFilters,
AllocationTemplateListItem,
AllocationTemplateDetail,
CreateAllocationTemplateItemRequest,
} from '../../../types/allocation-template.types';
const ITEMS_PER_PAGE = 20;
interface FormAllocationRow {
profileId: string;
quantity: number;
allocationPercentage: number;
}
const EMPTY_ROW: FormAllocationRow = { profileId: '', quantity: 1, allocationPercentage: 100 };
interface ClientAllocationTemplatesTabProps {
clientId: string;
readOnly?: boolean;
}
export function ClientAllocationTemplatesTab({
clientId,
readOnly = false,
}: ClientAllocationTemplatesTabProps) {
const [page, setPage] = useState(1);
const [itemToDelete, setItemToDelete] = useState<AllocationTemplateListItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewingId, setViewingId] = useState<string | null>(null);
// Form state
const [formSprintType, setFormSprintType] = useState<string>('');
const [formContractItemId, setFormContractItemId] = useState<string>('');
const [formRows, setFormRows] = useState<FormAllocationRow[]>([{ ...EMPTY_ROW }]);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [drawerLoading, setDrawerLoading] = useState(false);
const [viewDetail, setViewDetail] = useState<AllocationTemplateDetail | null>(null);
const { showToast } = useToast();
const queryClient = useQueryClient();
const invalidateTemplates = () => {
void queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'allocation-templates'] });
};
// Queries
const filters: AllocationTemplateFilters = useMemo(
() => ({ page, limit: ITEMS_PER_PAGE }),
[page],
);
const { data, isLoading } = useAllocationTemplates(clientId, filters);
const { data: activeContractItems } = useClientActiveContractItems(clientId);
const { data: activeProfiles } = useClientActiveProfiles(clientId);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const contractItemOptions = useMemo(
() =>
(activeContractItems ?? []).map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`,
})),
[activeContractItems],
);
const profileOptions = useMemo(
() => (activeProfiles ?? []).map((p) => ({ value: p.id, label: p.name })),
[activeProfiles],
);
// Mutations
const createMutation = useMutation({
mutationFn: (payload: {
sprintType: SprintType;
contractItemId: string;
items: CreateAllocationTemplateItemRequest[];
}) => createAllocationTemplate(clientId, payload),
onSuccess: () => {
invalidateTemplates();
showToast('Template de alocação criado com sucesso', 'success');
closeDrawer();
},
onError: () => {
showToast('Erro ao criar template de alocação', 'error');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, items }: { id: string; items: CreateAllocationTemplateItemRequest[] }) =>
updateAllocationTemplate(clientId, id, { items }),
onSuccess: () => {
invalidateTemplates();
showToast('Template de alocação atualizado com sucesso', 'success');
closeDrawer();
},
onError: () => {
showToast('Erro ao atualizar template de alocação', 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAllocationTemplate(clientId, id),
onSuccess: () => {
invalidateTemplates();
showToast('Template de alocação excluído com sucesso', 'success');
setItemToDelete(null);
},
onError: () => {
showToast('Erro ao excluir template de alocação', 'error');
setItemToDelete(null);
},
});
// Drawer handlers
function openCreateDrawer() {
setEditingId(null);
setViewingId(null);
setFormSprintType('');
setFormContractItemId('');
setFormRows([{ ...EMPTY_ROW }]);
setFormErrors({});
setDrawerOpen(true);
}
function openViewDrawer(template: AllocationTemplateListItem) {
setEditingId(null);
setFormErrors({});
setViewingId(template.id);
setViewDetail(null);
setDrawerLoading(true);
setDrawerOpen(true);
getAllocationTemplate(clientId, template.id)
.then((detail) => setViewDetail(detail))
.catch(() => showToast('Erro ao carregar detalhes do template', 'error'))
.finally(() => setDrawerLoading(false));
}
function openEditDrawer(template: AllocationTemplateListItem) {
setViewingId(null);
setViewDetail(null);
setFormErrors({});
setDrawerLoading(true);
setDrawerOpen(true);
getAllocationTemplate(clientId, template.id)
.then((detail) => {
setEditingId(detail.id);
setFormSprintType(detail.sprintType);
setFormContractItemId(detail.contractItem.id);
setFormRows(
detail.items.map((item) => ({
profileId: item.profile.id,
quantity: Number(item.quantity),
allocationPercentage: Number(item.allocationPercentage),
})),
);
})
.catch(() => showToast('Erro ao carregar detalhes do template', 'error'))
.finally(() => setDrawerLoading(false));
}
function closeDrawer() {
setDrawerOpen(false);
setEditingId(null);
setViewingId(null);
setViewDetail(null);
setFormSprintType('');
setFormContractItemId('');
setFormRows([{ ...EMPTY_ROW }]);
setFormErrors({});
}
// Form row handlers
function addRow() {
setFormRows((prev) => [...prev, { ...EMPTY_ROW }]);
}
function removeRow(index: number) {
setFormRows((prev) => prev.filter((_, i) => i !== index));
}
function updateRow(index: number, field: keyof FormAllocationRow, value: string | number) {
setFormRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
}
function validateForm(): boolean {
const errors: Record<string, string> = {};
if (!editingId) {
if (!formSprintType) errors.sprintType = 'Tipo de sprint é obrigatório';
if (!formContractItemId) errors.contractItemId = 'Item de contrato é obrigatório';
}
if (formRows.length === 0) {
errors.rows = 'Adicione ao menos um perfil';
}
formRows.forEach((row, i) => {
if (!row.profileId) errors[`row_${i}_profile`] = 'Selecione um perfil';
if (row.quantity < 1) errors[`row_${i}_quantity`] = 'Mínimo 1';
if (row.allocationPercentage <= 0 || row.allocationPercentage > 100)
errors[`row_${i}_allocation`] = 'Entre 1 e 100';
});
setFormErrors(errors);
return Object.keys(errors).length === 0;
}
function handleSubmit() {
if (!validateForm()) return;
const items: CreateAllocationTemplateItemRequest[] = formRows.map((row) => ({
profileId: row.profileId,
quantity: Number(row.quantity),
allocationPercentage: Number(row.allocationPercentage),
}));
if (editingId) {
updateMutation.mutate({ id: editingId, items });
} else {
createMutation.mutate({
sprintType: formSprintType as SprintType,
contractItemId: formContractItemId,
items,
});
}
}
const isSaving = createMutation.isPending || updateMutation.isPending;
const columns = [
{
key: 'sprintType',
header: 'Tipo de Sprint',
render: (item: AllocationTemplateListItem) => (
<Badge variant={SPRINT_TYPE_VARIANTS[item.sprintType] as 'info'}>
{SPRINT_TYPE_LABELS[item.sprintType]}
</Badge>
),
},
{
key: 'contractItem',
header: 'Item de Contrato',
render: (item: AllocationTemplateListItem) => (
<span>
<span className="text-primary font-medium">{item.contractItem.code}</span>
<span className="text-text-secondary"> - {item.contractItem.name}</span>
</span>
),
},
{
key: 'profileCount',
header: 'Qtd Perfis',
render: (item: AllocationTemplateListItem) => (
<span className="text-text-secondary">{item._count.items}</span>
),
},
{
key: 'actions',
header: 'Ações',
render: (item: AllocationTemplateListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => openViewDrawer(item)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!readOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDrawer(item)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!readOnly && (
<Tooltip content="Excluir">
<Button
variant="ghost"
size="icon"
onClick={() => setItemToDelete(item)}
className="text-danger hover:text-danger/80"
aria-label="Excluir"
>
<Trash2 size={14} />
</Button>
</Tooltip>
)}
</div>
),
},
];
const isViewMode = !!viewingId;
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1" />
{!readOnly && (
<Button onClick={openCreateDrawer} icon={<Plus size={16} />}>
Novo Template
</Button>
)}
</div>
<DataTable<AllocationTemplateListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum template de alocação encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="template"
itemLabelPlural="templates"
onPageChange={setPage}
/>
<Drawer
open={drawerOpen}
onClose={closeDrawer}
title={
isViewMode ? 'Detalhes do Template' : editingId ? 'Editar Template' : 'Novo Template'
}
width="max-w-2xl"
>
{isViewMode && drawerLoading ? (
<div className="flex items-center justify-center py-8">
<span className="text-text-secondary">Carregando...</span>
</div>
) : isViewMode && viewDetail ? (
<div className="space-y-4">
<div>
<span className="text-small text-text-secondary">Tipo de Sprint</span>
<div className="mt-1">
<Badge variant={SPRINT_TYPE_VARIANTS[viewDetail.sprintType] as 'info'}>
{SPRINT_TYPE_LABELS[viewDetail.sprintType]}
</Badge>
</div>
</div>
<div>
<span className="text-small text-text-secondary">Item de Contrato</span>
<p className="mt-1 text-primary">
{viewDetail.contractItem.code} - {viewDetail.contractItem.name}
</p>
</div>
<div>
<span className="text-small text-text-secondary">Perfis Alocados</span>
<div className="mt-2 space-y-2">
{viewDetail.items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between rounded border px-3 py-2"
>
<span className="font-medium text-primary">{item.profile.name}</span>
<div className="flex items-center gap-4 text-small text-text-secondary">
<span>Qtd: {item.quantity}</span>
<span>Alocação: {Number(item.allocationPercentage)}%</span>
</div>
</div>
))}
{viewDetail.items.length === 0 && (
<p className="text-text-muted text-small">Nenhum perfil alocado</p>
)}
</div>
</div>
</div>
) : (
<div className="space-y-4">
<Select
label="Tipo de Sprint"
options={SPRINT_TYPE_OPTIONS}
placeholder="Selecione o tipo"
value={formSprintType}
onChange={(e) => setFormSprintType(e.target.value)}
error={formErrors.sprintType}
disabled={!!editingId}
/>
<Select
label="Item de Contrato"
options={contractItemOptions}
placeholder="Selecione o item"
value={formContractItemId}
onChange={(e) => setFormContractItemId(e.target.value)}
error={formErrors.contractItemId}
disabled={!!editingId}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-small text-text-secondary">Perfis</span>
<Button variant="ghost" size="sm" onClick={addRow} icon={<Plus size={14} />}>
Adicionar Perfil
</Button>
</div>
{formErrors.rows && <p className="mb-2 text-small text-danger">{formErrors.rows}</p>}
<div className="space-y-3">
{formRows.map((row, index) => (
<div key={index} className="flex items-start gap-2 rounded border p-3">
<div className="flex-1">
<Select
label="Perfil"
options={profileOptions}
placeholder="Selecione"
value={row.profileId}
onChange={(e) => updateRow(index, 'profileId', e.target.value)}
error={formErrors[`row_${index}_profile`]}
/>
</div>
<div className="w-24">
<Input
label="Qtd"
type="number"
min={1}
value={row.quantity}
onChange={(e) => updateRow(index, 'quantity', Number(e.target.value))}
error={formErrors[`row_${index}_quantity`]}
/>
</div>
<div className="w-28">
<Input
label="Alocação %"
type="number"
min={1}
max={100}
value={row.allocationPercentage}
onChange={(e) =>
updateRow(index, 'allocationPercentage', Number(e.target.value))
}
error={formErrors[`row_${index}_allocation`]}
/>
</div>
<div className="pt-6">
<Button
variant="ghost"
size="icon"
onClick={() => removeRow(index)}
className="text-danger hover:text-danger/80"
aria-label="Remover perfil"
disabled={formRows.length <= 1}
>
<X size={14} />
</Button>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={closeDrawer} disabled={isSaving}>
Cancelar
</Button>
<Button onClick={handleSubmit} loading={isSaving}>
{editingId ? 'Salvar' : 'Criar'}
</Button>
</div>
</div>
)}
</Drawer>
<ConfirmDialog
open={!!itemToDelete}
title="Excluir template de alocação"
message={`Tem certeza que deseja excluir o template "${itemToDelete ? SPRINT_TYPE_LABELS[itemToDelete.sprintType] : ''} - ${itemToDelete?.contractItem.name ?? ''}"?`}
confirmLabel="Excluir"
variant="danger"
loading={deleteMutation.isPending}
onConfirm={() => {
if (itemToDelete) deleteMutation.mutate(itemToDelete.id);
}}
onCancel={() => setItemToDelete(null)}
/>
</>
);
}

View File

@@ -0,0 +1,361 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
DetailField,
} from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { Drawer } from '../../../components/ui/Drawer';
import { useClientContractItems } from '../../../hooks/useContractItems';
import { toggleContractItemStatus } from '../../../services/contract-items.service';
import {
ContractItemType,
type ContractItemFilters,
type ContractItemListItem,
} from '../../../types/contract-item.types';
import {
CONTRACT_ITEM_TYPE_LABELS,
getContractItemTypeBadgeConfig,
} from '../../../constants/contract-item-type';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const TYPE_OPTIONS = [
{ value: ContractItemType.UST, label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.UST] },
{
value: ContractItemType.SAAS_LICENSE,
label: CONTRACT_ITEM_TYPE_LABELS[ContractItemType.SAAS_LICENSE],
},
];
const ITEMS_PER_PAGE = 20;
interface ClientContractItemsTabProps {
clientId: string;
readOnly?: boolean;
}
export function ClientContractItemsTab({
clientId,
readOnly = false,
}: ClientContractItemsTabProps) {
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [itemToToggle, setItemToToggle] = useState<ContractItemListItem | null>(null);
const [itemToView, setItemToView] = useState<ContractItemListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (item: ContractItemListItem) => toggleContractItemStatus(clientId, item.id),
onSuccess: (_data, item) => {
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'contract-items'] });
showToast(
item.isActive
? 'Item de contrato inativado com sucesso'
: 'Item de contrato ativado com sucesso',
'success',
);
setItemToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do item de contrato', 'error');
setItemToToggle(null);
},
});
const filters: ContractItemFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
itemType: typeFilter ? (typeFilter as ContractItemType) : undefined,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, typeFilter, page],
);
const { data, isLoading } = useClientContractItems(clientId, filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
function handleTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {
setTypeFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'code',
header: 'Código',
render: (item: ContractItemListItem) => (
<span className="text-primary font-medium">{item.code}</span>
),
},
{
key: 'name',
header: 'Nome',
render: (item: ContractItemListItem) => <span>{item.name}</span>,
},
{
key: 'description',
header: 'Descrição',
render: (item: ContractItemListItem) => (
<span className="text-text-secondary max-w-[200px] truncate block">
{item.description ?? '—'}
</span>
),
},
{
key: 'itemType',
header: 'Tipo',
render: (item: ContractItemListItem) => {
const config = getContractItemTypeBadgeConfig(item.itemType);
return (
<Badge variant={config.color === 'blue' ? 'info' : 'neutral'}>{config.label}</Badge>
);
},
},
{
key: 'totalUst',
header: 'Total UST',
render: (item: ContractItemListItem) => (
<span className="text-text-secondary">{Number(item.totalUst)}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (item: ContractItemListItem) => (
<Badge variant={item.isActive ? 'success' : 'danger'}>
{item.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (item: ContractItemListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setItemToView(item)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!readOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/clientes/${clientId}/itens-contrato/${item.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!readOnly && (
<Tooltip content={item.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setItemToToggle(item)}
className={
item.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={item.isActive ? 'Inativar' : 'Ativar'}
>
{item.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate, clientId, readOnly],
);
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome ou código..."
/>
</div>
<Select
options={TYPE_OPTIONS}
placeholder="Todos os tipos"
value={typeFilter}
onChange={handleTypeChange}
/>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
{!readOnly && (
<Button
onClick={() => navigate(`/clientes/${clientId}/itens-contrato/novo`)}
icon={<Plus size={16} />}
>
Novo Item
</Button>
)}
</div>
<DataTable<ContractItemListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum item de contrato encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="item"
itemLabelPlural="itens"
onPageChange={setPage}
/>
<Drawer
open={!!itemToView}
onClose={() => setItemToView(null)}
title="Detalhes do Item de Contrato"
>
{itemToView && (
<div className="space-y-4">
<DetailField label="Código" value={itemToView.code} />
<DetailField label="Nome" value={itemToView.name} />
<DetailField label="Descrição" value={itemToView.description} />
<DetailField label="Tipo" value={CONTRACT_ITEM_TYPE_LABELS[itemToView.itemType]} />
<DetailField
label={
itemToView.itemType === ContractItemType.SAAS_LICENSE ? 'Quantidade' : 'Total UST'
}
value={String(Number(itemToView.totalUst))}
/>
<DetailField
label="Valor da UST (R$)"
value={
itemToView.ustValue != null
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(
Number(itemToView.ustValue),
)
: '—'
}
/>
<div className="border-t border-border pt-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted mb-3">
Carga Horária Semanal por Tipo de Sprint
</p>
<div className="grid grid-cols-2 gap-3">
<DetailField
label="Descoberta"
value={
itemToView.timeboxDescoberta != null
? `${Number(itemToView.timeboxDescoberta)}h/sem`
: '—'
}
/>
<DetailField
label="Design"
value={
itemToView.timeboxDesign != null
? `${Number(itemToView.timeboxDesign)}h/sem`
: '—'
}
/>
<DetailField
label="Arquitetura"
value={
itemToView.timeboxArquitetura != null
? `${Number(itemToView.timeboxArquitetura)}h/sem`
: '—'
}
/>
<DetailField
label="Construção"
value={
itemToView.timeboxConstrucao != null
? `${Number(itemToView.timeboxConstrucao)}h/sem`
: '—'
}
/>
</div>
</div>
<DetailField
label="Status"
value={
<Badge variant={itemToView.isActive ? 'success' : 'danger'}>
{itemToView.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
</div>
)}
</Drawer>
<ConfirmDialog
open={!!itemToToggle}
title={itemToToggle?.isActive ? 'Inativar item de contrato' : 'Ativar item de contrato'}
message={
itemToToggle?.isActive
? `Tem certeza que deseja inativar o item "${itemToToggle?.name}"?`
: `Tem certeza que deseja ativar o item "${itemToToggle?.name}"?`
}
confirmLabel={itemToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={itemToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (itemToToggle) toggleStatusMutation.mutate(itemToToggle);
}}
onCancel={() => setItemToToggle(null)}
/>
</>
);
}

View File

@@ -0,0 +1,258 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { ContractDetailDrawer } from '../../contracts/ContractDetailDrawer';
import { useClientContracts } from '../../../hooks/useContracts';
import { updateContract } from '../../../services/contracts.service';
import { numberToCurrencyString } from '../../../components/ui/CurrencyInput';
import type { ContractsFilters, ContractListItem } from '../../../types/contract.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
interface ClientContractsTabProps {
clientId: string;
readOnly?: boolean;
}
export function ClientContractsTab({ clientId, readOnly = false }: ClientContractsTabProps) {
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [contractToToggle, setContractToToggle] = useState<ContractListItem | null>(null);
const [contractToView, setContractToView] = useState<ContractListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (contract: ContractListItem) =>
updateContract(contract.id, { isActive: !contract.isActive }),
onSuccess: (_data, contract) => {
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'contracts'] });
showToast(
contract.isActive ? 'Contrato inativado com sucesso' : 'Contrato ativado com sucesso',
'success',
);
setContractToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do contrato', 'error');
setContractToToggle(null);
},
});
const filters: ContractsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useClientContracts(clientId, filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (c: ContractListItem) => <span className="text-primary">{c.name}</span>,
},
{
key: 'code',
header: 'Código',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{c.code ?? '—'}</span>
),
},
{
key: 'startDate',
header: 'Data Início',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{formatDate(c.startDate)}</span>
),
},
{
key: 'endDate',
header: 'Data Fim',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{formatDate(c.endDate)}</span>
),
},
{
key: 'ustValue',
header: 'Valor UST',
render: (c: ContractListItem) => (
<span className="text-text-secondary">
{c.ustValue != null ? numberToCurrencyString(Number(c.ustValue)) : '—'}
</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (c: ContractListItem) => (
<Badge variant={c.isActive ? 'success' : 'danger'}>
{c.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (c: ContractListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setContractToView(c)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!readOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/contratos/${c.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!readOnly && (
<Tooltip content={c.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setContractToToggle(c)}
className={
c.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={c.isActive ? 'Inativar' : 'Ativar'}
>
{c.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate],
);
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
{!readOnly && (
<Button
onClick={() => navigate(`/clientes/${clientId}/contratos/novo`)}
icon={<Plus size={16} />}
>
Novo Contrato
</Button>
)}
</div>
<DataTable<ContractListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum contrato encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="contrato"
itemLabelPlural="contratos"
onPageChange={setPage}
/>
<ContractDetailDrawer
open={!!contractToView}
contract={contractToView}
onClose={() => setContractToView(null)}
/>
<ConfirmDialog
open={!!contractToToggle}
title={contractToToggle?.isActive ? 'Inativar contrato' : 'Ativar contrato'}
message={
contractToToggle?.isActive
? `Tem certeza que deseja inativar o contrato "${contractToToggle?.name}"?`
: `Tem certeza que deseja ativar o contrato "${contractToToggle?.name}"?`
}
confirmLabel={contractToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={contractToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (contractToToggle) toggleStatusMutation.mutate(contractToToggle);
}}
onCancel={() => setContractToToggle(null)}
/>
</>
);
}

View File

@@ -0,0 +1,302 @@
import { useState, useMemo } from 'react';
import { Plus, Pencil, UserX, UserCheck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
Input,
} from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { Drawer } from '../../../components/ui/Drawer';
import { useClientProfiles } from '../../../hooks/useClientProfiles';
import {
createClientProfile,
updateClientProfile,
toggleClientProfileStatus,
} from '../../../services/client-profiles.service';
import type {
ClientProfileFilters,
ClientProfileListItem,
} from '../../../types/client-profile.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
interface ClientProfilesTabProps {
clientId: string;
readOnly?: boolean;
}
export function ClientProfilesTab({ clientId, readOnly = false }: ClientProfilesTabProps) {
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [itemToToggle, setItemToToggle] = useState<ClientProfileListItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<ClientProfileListItem | null>(null);
const [formName, setFormName] = useState('');
const [formError, setFormError] = useState('');
const { showToast } = useToast();
const queryClient = useQueryClient();
const invalidateProfiles = () => {
void queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'profiles'] });
};
const createMutation = useMutation({
mutationFn: (name: string) => createClientProfile(clientId, { name }),
onSuccess: () => {
invalidateProfiles();
showToast('Perfil criado com sucesso', 'success');
closeDrawer();
},
onError: () => {
showToast('Erro ao criar perfil', 'error');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
updateClientProfile(clientId, id, { name }),
onSuccess: () => {
invalidateProfiles();
showToast('Perfil atualizado com sucesso', 'success');
closeDrawer();
},
onError: () => {
showToast('Erro ao atualizar perfil', 'error');
},
});
const toggleStatusMutation = useMutation({
mutationFn: (item: ClientProfileListItem) => toggleClientProfileStatus(clientId, item.id),
onSuccess: (_data, item) => {
invalidateProfiles();
showToast(
item.isActive ? 'Perfil inativado com sucesso' : 'Perfil ativado com sucesso',
'success',
);
setItemToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do perfil', 'error');
setItemToToggle(null);
},
});
const filters: ClientProfileFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useClientProfiles(clientId, filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
function openCreateDrawer() {
setEditingProfile(null);
setFormName('');
setFormError('');
setDrawerOpen(true);
}
function openEditDrawer(profile: ClientProfileListItem) {
setEditingProfile(profile);
setFormName(profile.name);
setFormError('');
setDrawerOpen(true);
}
function closeDrawer() {
setDrawerOpen(false);
setEditingProfile(null);
setFormName('');
setFormError('');
}
function handleSubmit() {
const trimmed = formName.trim();
if (!trimmed) {
setFormError('Nome é obrigatório');
return;
}
if (editingProfile) {
updateMutation.mutate({ id: editingProfile.id, name: trimmed });
} else {
createMutation.mutate(trimmed);
}
}
const isSaving = createMutation.isPending || updateMutation.isPending;
const columns = [
{
key: 'name',
header: 'Nome',
render: (item: ClientProfileListItem) => (
<span className="font-medium text-primary">{item.name}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (item: ClientProfileListItem) => (
<Badge variant={item.isActive ? 'success' : 'danger'}>
{item.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (item: ClientProfileListItem) => (
<div className="flex items-center gap-3">
{!readOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDrawer(item)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!readOnly && (
<Tooltip content={item.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setItemToToggle(item)}
className={
item.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={item.isActive ? 'Inativar' : 'Ativar'}
>
{item.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
)}
</div>
),
},
];
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
{!readOnly && (
<Button onClick={openCreateDrawer} icon={<Plus size={16} />}>
Novo Perfil
</Button>
)}
</div>
<DataTable<ClientProfileListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum perfil encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="perfil"
itemLabelPlural="perfis"
onPageChange={setPage}
/>
<Drawer
open={drawerOpen}
onClose={closeDrawer}
title={editingProfile ? 'Editar Perfil' : 'Novo Perfil'}
>
<div className="space-y-4">
<Input
label="Nome"
value={formName}
onChange={(e) => {
setFormName(e.target.value);
if (formError) setFormError('');
}}
error={formError}
placeholder="Nome do perfil"
/>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={closeDrawer} disabled={isSaving}>
Cancelar
</Button>
<Button onClick={handleSubmit} loading={isSaving}>
{editingProfile ? 'Salvar' : 'Criar'}
</Button>
</div>
</div>
</Drawer>
<ConfirmDialog
open={!!itemToToggle}
title={itemToToggle?.isActive ? 'Inativar perfil' : 'Ativar perfil'}
message={
itemToToggle?.isActive
? `Tem certeza que deseja inativar o perfil "${itemToToggle?.name}"?`
: `Tem certeza que deseja ativar o perfil "${itemToToggle?.name}"?`
}
confirmLabel={itemToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={itemToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (itemToToggle) toggleStatusMutation.mutate(itemToToggle);
}}
onCancel={() => setItemToToggle(null)}
/>
</>
);
}

View File

@@ -0,0 +1,255 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { ProjectDetailDrawer } from '../../projects/ProjectDetailDrawer';
import { useClientProjects } from '../../../hooks/useProjects';
import { updateProject } from '../../../services/projects.service';
import type { ProjectsFilters, ProjectListItem } from '../../../types/project.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
interface ClientProjectsTabProps {
clientId: string;
readOnly?: boolean;
}
export function ClientProjectsTab({ clientId, readOnly = false }: ClientProjectsTabProps) {
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [projectToToggle, setProjectToToggle] = useState<ProjectListItem | null>(null);
const [projectToView, setProjectToView] = useState<ProjectListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (project: ProjectListItem) =>
updateProject(project.id, { isActive: !project.isActive }),
onSuccess: (_data, project) => {
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] });
showToast(
project.isActive ? 'Projeto inativado com sucesso' : 'Projeto ativado com sucesso',
'success',
);
setProjectToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do projeto', 'error');
setProjectToToggle(null);
},
});
const filters: ProjectsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useClientProjects(clientId, filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (p: ProjectListItem) => <span className="text-primary">{p.name}</span>,
},
{
key: 'code',
header: 'Código',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{p.code ?? '—'}</span>
),
},
{
key: 'contract',
header: 'Contrato',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{p.contract.name}</span>
),
},
{
key: 'startDate',
header: 'Data Início',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{formatDate(p.startDate)}</span>
),
},
{
key: 'endDate',
header: 'Data Fim',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{formatDate(p.endDate)}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (p: ProjectListItem) => (
<Badge variant={p.isActive ? 'success' : 'danger'}>
{p.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (p: ProjectListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setProjectToView(p)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!readOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/projetos/${p.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!readOnly && (
<Tooltip content={p.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setProjectToToggle(p)}
className={
p.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={p.isActive ? 'Inativar' : 'Ativar'}
>
{p.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate],
);
return (
<>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
{!readOnly && (
<Button
onClick={() => navigate(`/clientes/${clientId}/projetos/novo`)}
icon={<Plus size={16} />}
>
Novo Projeto
</Button>
)}
</div>
<DataTable<ProjectListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum projeto encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="projeto"
itemLabelPlural="projetos"
onPageChange={setPage}
/>
<ProjectDetailDrawer
open={!!projectToView}
project={projectToView}
onClose={() => setProjectToView(null)}
/>
<ConfirmDialog
open={!!projectToToggle}
title={projectToToggle?.isActive ? 'Inativar projeto' : 'Ativar projeto'}
message={
projectToToggle?.isActive
? `Tem certeza que deseja inativar o projeto "${projectToToggle?.name}"?`
: `Tem certeza que deseja ativar o projeto "${projectToToggle?.name}"?`
}
confirmLabel={projectToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={projectToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (projectToToggle) toggleStatusMutation.mutate(projectToToggle);
}}
onCancel={() => setProjectToToggle(null)}
/>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ClientContractItemsTab } from '../ClientContractItemsTab';
const useClientContractItemsMock = vi.fn();
vi.mock('../../../../hooks/useContractItems', () => ({
useClientContractItems: (...args: unknown[]) => useClientContractItemsMock(...args),
}));
vi.mock('../../../../services/contract-items.service', () => ({
toggleContractItemStatus: vi.fn(),
}));
vi.mock('../../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
const baseItems = [
{
id: 'item-1',
code: 'IC-1',
name: 'Item UST',
description: null,
itemType: 'UST' as const,
totalUst: 1000,
ustValue: 100,
timeboxDescoberta: null,
timeboxDesign: null,
timeboxArquitetura: null,
timeboxConstrucao: null,
isActive: true,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
client: { id: 'cli-1', name: 'Cliente' },
},
{
id: 'item-2',
code: 'IC-2',
name: 'Item SaaS',
description: null,
itemType: 'SAAS_LICENSE' as const,
totalUst: 50,
ustValue: 200,
timeboxDescoberta: null,
timeboxDesign: null,
timeboxArquitetura: null,
timeboxConstrucao: null,
isActive: true,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
client: { id: 'cli-1', name: 'Cliente' },
},
];
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<ClientContractItemsTab clientId="cli-1" />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('ClientContractItemsTab', () => {
it('renderiza coluna Tipo com label correto para UST e SAAS_LICENSE', () => {
useClientContractItemsMock.mockReturnValue({
data: { data: baseItems, total: 2, page: 1, limit: 20 },
isLoading: false,
});
renderTab();
expect(screen.getAllByText('Unidade de Serviço Técnico (UST)').length).toBeGreaterThan(0);
expect(screen.getAllByText('Licença SaaS').length).toBeGreaterThan(0);
expect(screen.getByText('Item UST')).toBeInTheDocument();
expect(screen.getByText('Item SaaS')).toBeInTheDocument();
});
it('aciona hook com filtro itemType ao selecionar Licença SaaS', async () => {
useClientContractItemsMock.mockReturnValue({
data: { data: baseItems, total: 2, page: 1, limit: 20 },
isLoading: false,
});
const user = userEvent.setup();
renderTab();
const typeSelect = screen.getByDisplayValue('Todos os tipos');
await user.selectOptions(typeSelect, 'SAAS_LICENSE');
const lastCall =
useClientContractItemsMock.mock.calls[useClientContractItemsMock.mock.calls.length - 1];
expect(lastCall[1]).toEqual(expect.objectContaining({ itemType: 'SAAS_LICENSE' }));
});
});

View File

View File

@@ -0,0 +1,153 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import {
Button,
Input,
Select,
CurrencyInput,
parseCurrencyToNumber,
DatePickerField,
} from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createContract } from '../../services/contracts.service';
import { useClients } from '../../hooks/useClients';
const createContractSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
clientId: z.string().min(1, 'Cliente é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
ustValue: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type CreateContractFormData = z.infer<typeof createContractSchema>;
export function ContractCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: clientsData } = useClients({ isActive: 'true', page: 1, limit: 100 });
const clientOptions = (clientsData?.data ?? []).map((c) => ({
value: c.id,
label: c.name,
}));
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateContractFormData>({
resolver: zodResolver(createContractSchema),
});
async function onSubmit(data: CreateContractFormData) {
setApiError(null);
try {
await createContract({
name: data.name,
clientId: data.clientId,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
ustValue: parseCurrencyToNumber(data.ustValue ?? ''),
});
await queryClient.invalidateQueries({ queryKey: ['contracts'] });
showToast('Contrato criado com sucesso', 'success');
navigate('/contratos');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar contrato. Tente novamente.');
} else {
setApiError('Erro ao criar contrato. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Contrato" breadcrumbs={breadcrumbs} />
<FormCard title="Novo Contrato">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do contrato"
error={errors.name?.message}
{...register('name')}
/>
<Select
label="Cliente"
options={clientOptions}
placeholder="Selecione um cliente"
error={errors.clientId?.message}
{...register('clientId')}
/>
<Input
label="Código"
placeholder="Código do contrato"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do contrato"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
<CurrencyInput
label="Valor da UST"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/contratos')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { Drawer, DetailField, Badge } from '../../components/ui';
import type { ContractListItem } from '../../types/contract.types';
interface ContractDetailDrawerProps {
open: boolean;
contract: ContractListItem | null;
onClose: () => void;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
function formatCurrency(value: number | null): string {
if (value == null) return '—';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
export function ContractDetailDrawer({ open, contract, onClose }: ContractDetailDrawerProps) {
return (
<Drawer open={open} title="Detalhes do Contrato" onClose={onClose}>
{contract && (
<div className="flex flex-col gap-5">
<DetailField label="Nome" value={contract.name} />
<DetailField label="Código" value={contract.code} />
<DetailField label="Cliente" value={contract.client.name} />
<DetailField label="Descrição" value={contract.description} />
<DetailField label="Data de Início" value={formatDate(contract.startDate)} />
<DetailField label="Data de Término" value={formatDate(contract.endDate)} />
<DetailField label="Valor da UST" value={formatCurrency(contract.ustValue)} />
<DetailField
label="Status"
value={
<Badge variant={contract.isActive ? 'success' : 'danger'}>
{contract.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
<DetailField label="Criado em" value={formatDate(contract.createdAt)} />
<DetailField label="Atualizado em" value={formatDate(contract.updatedAt)} />
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import {
Button,
Input,
Select,
CurrencyInput,
parseCurrencyToNumber,
numberToCurrencyString,
DatePickerField,
} from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useContract } from '../../hooks/useContracts';
import { useClients } from '../../hooks/useClients';
import { updateContract } from '../../services/contracts.service';
const editContractSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
clientId: z.string().min(1, 'Cliente é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
ustValue: z.string().optional(),
isActive: z.boolean(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type EditContractFormData = z.infer<typeof editContractSchema>;
function toDateInputValue(dateStr: string | null): string {
if (!dateStr) return '';
return dateStr.slice(0, 10);
}
export function ContractEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: contract, isLoading: isLoadingContract, isError } = useContract(id ?? '');
const { data: clientsData } = useClients({ isActive: 'true', page: 1, limit: 100 });
const breadcrumbs = useBreadcrumbs({ ':name': contract?.name ?? '' });
const clientOptions = (clientsData?.data ?? []).map((c) => ({
value: c.id,
label: c.name,
}));
const {
register,
handleSubmit,
reset,
watch,
setValue,
control,
formState: { errors, isSubmitting },
} = useForm<EditContractFormData>({
resolver: zodResolver(editContractSchema),
});
const isActive = watch('isActive');
useEffect(() => {
if (contract) {
reset({
name: contract.name,
clientId: contract.client.id,
code: contract.code ?? '',
description: contract.description ?? '',
startDate: toDateInputValue(contract.startDate),
endDate: toDateInputValue(contract.endDate),
ustValue: numberToCurrencyString(contract.ustValue),
isActive: contract.isActive,
});
}
}, [contract, reset]);
async function onSubmit(data: EditContractFormData) {
if (!id) return;
setApiError(null);
try {
await updateContract(id, {
name: data.name,
clientId: data.clientId,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
ustValue: parseCurrencyToNumber(data.ustValue ?? ''),
isActive: data.isActive,
});
await queryClient.invalidateQueries({ queryKey: ['contracts'] });
showToast('Contrato atualizado com sucesso', 'success');
navigate('/contratos');
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 404) {
setApiError('Contrato não encontrado');
} else {
setApiError(
err.response?.data?.message ?? 'Erro ao atualizar contrato. Tente novamente.',
);
}
} else {
setApiError('Erro ao atualizar contrato. Tente novamente.');
}
}
}
if (isLoadingContract) {
return (
<PageContainer>
<PageHeader title="Editar Contrato" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !contract) {
return (
<PageContainer>
<PageHeader title="Editar Contrato" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Contrato não encontrado</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Contrato" breadcrumbs={breadcrumbs} />
<FormCard title="Editar Contrato">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do contrato"
error={errors.name?.message}
{...register('name')}
/>
<Select
label="Cliente"
options={clientOptions}
placeholder="Selecione um cliente"
error={errors.clientId?.message}
{...register('clientId')}
/>
<Input
label="Código"
placeholder="Código do contrato"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do contrato"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
<CurrencyInput
label="Valor da UST"
error={errors.ustValue?.message}
{...register('ustValue')}
/>
<div className="flex items-center justify-between rounded border px-4 py-3">
<span className="text-body text-primary">Status</span>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={isActive ?? true}
onChange={(e) => setValue('isActive', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-danger/40 transition-colors peer-checked:bg-success peer-focus:ring-2 peer-focus:ring-isis-blue after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full" />
<span className="ml-2 text-small text-text-secondary">
{isActive ? 'Ativo' : 'Inativo'}
</span>
</label>
</div>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/contratos')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,250 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../components/ui';
import { useContracts } from '../../hooks/useContracts';
import { useToast } from '../../components/ui/Toast';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { ContractDetailDrawer } from './ContractDetailDrawer';
import { updateContract } from '../../services/contracts.service';
import type { ContractsFilters, ContractListItem } from '../../types/contract.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function ContractsPage() {
const breadcrumbs = useBreadcrumbs();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [contractToToggle, setContractToToggle] = useState<ContractListItem | null>(null);
const [contractToView, setContractToView] = useState<ContractListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (contract: ContractListItem) =>
updateContract(contract.id, { isActive: !contract.isActive }),
onSuccess: (_data, contract) => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
showToast(
contract.isActive ? 'Contrato inativado com sucesso' : 'Contrato ativado com sucesso',
'success',
);
setContractToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do contrato', 'error');
setContractToToggle(null);
},
});
const filters: ContractsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useContracts(filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (c: ContractListItem) => <span className="text-primary">{c.name}</span>,
},
{
key: 'code',
header: 'Código',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{c.code ?? '—'}</span>
),
},
{
key: 'client',
header: 'Cliente',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{c.client.name}</span>
),
},
{
key: 'startDate',
header: 'Data Início',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{formatDate(c.startDate)}</span>
),
},
{
key: 'endDate',
header: 'Data Fim',
render: (c: ContractListItem) => (
<span className="text-text-secondary">{formatDate(c.endDate)}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (c: ContractListItem) => (
<Badge variant={c.isActive ? 'success' : 'danger'}>
{c.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (c: ContractListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setContractToView(c)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/contratos/${c.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
<Tooltip content={c.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setContractToToggle(c)}
className={
c.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={c.isActive ? 'Inativar' : 'Ativar'}
>
{c.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
</div>
),
},
],
[navigate],
);
return (
<PageContainer>
<PageHeader
title="Contratos"
breadcrumbs={breadcrumbs}
actions={
<Button onClick={() => navigate('/contratos/novo')} icon={<Plus size={16} />}>
Novo Contrato
</Button>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
</div>
<DataTable<ContractListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum contrato encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="contrato"
itemLabelPlural="contratos"
onPageChange={setPage}
/>
<ContractDetailDrawer
open={!!contractToView}
contract={contractToView}
onClose={() => setContractToView(null)}
/>
<ConfirmDialog
open={!!contractToToggle}
title={contractToToggle?.isActive ? 'Inativar contrato' : 'Ativar contrato'}
message={
contractToToggle?.isActive
? `Tem certeza que deseja inativar o contrato "${contractToToggle?.name}"?`
: `Tem certeza que deseja ativar o contrato "${contractToToggle?.name}"?`
}
confirmLabel={contractToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={contractToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (contractToToggle) toggleStatusMutation.mutate(contractToToggle);
}}
onCancel={() => setContractToToggle(null)}
/>
</PageContainer>
);
}

View File

View File

@@ -0,0 +1,429 @@
import { useState, useMemo } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
FileText,
Play,
Clock,
CalendarPlus,
Plus,
CirclePlus,
Pencil,
RefreshCw,
Users,
ClipboardList,
MessageSquare,
UserCheck,
ArrowRight,
DollarSign,
CheckCircle,
TrendingUp,
} from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { DateRangePicker } from '../../components/ui';
import { StatusBadge } from '../../components/shared/StatusBadge';
import {
useDashboardSummary,
useRecentDeliverable,
useRecentActivity,
useDashboardBilling,
} from '../../hooks/useDashboard';
import { useReadOnly } from '../../hooks/useReadOnly';
import { DeliverableStatus, TimelineEventType } from '../../types/deliverable.types';
import type {
RecentDeliverable,
RecentActivity,
TopValueDeliverable,
} from '../../types/dashboard.types';
const EVENT_TYPE_ICONS: Record<TimelineEventType, typeof CirclePlus> = {
[TimelineEventType.CRIACAO]: CirclePlus,
[TimelineEventType.EDICAO]: Pencil,
[TimelineEventType.STATUS_CHANGE]: RefreshCw,
[TimelineEventType.ASSIGNMENT]: Users,
[TimelineEventType.BACKLOG]: ClipboardList,
[TimelineEventType.ANOTACAO]: MessageSquare,
[TimelineEventType.ALOCACAO]: UserCheck,
[TimelineEventType.VALOR_RECALCULADO]: RefreshCw,
};
function formatRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return 'agora';
if (diffMinutes < 60) return `${diffMinutes}min`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 30) return `${diffDays}d`;
return date.toLocaleDateString('pt-BR');
}
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function SkeletonCard() {
return (
<div className="animate-pulse rounded-lg border border-border bg-card p-5">
<div className="mb-3 h-4 w-20 rounded bg-skeleton" />
<div className="h-8 w-16 rounded bg-skeleton" />
</div>
);
}
function SkeletonList({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="animate-pulse rounded-lg border border-border bg-card p-4">
<div className="mb-2 h-4 w-3/4 rounded bg-skeleton" />
<div className="h-3 w-1/2 rounded bg-skeleton" />
</div>
))}
</div>
);
}
interface IndicatorCardProps {
label: string;
value: number;
icon: typeof FileText;
color: string;
onClick: () => void;
}
function IndicatorCard({ label, value, icon: Icon, color, onClick }: IndicatorCardProps) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-5 text-left transition-colors hover:border-isis-blue/40 hover:shadow-sm"
>
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${color}`}>
<Icon className="h-6 w-6" />
</div>
<div>
<p className="text-small text-text-secondary">{label}</p>
<p className="text-2xl font-bold text-primary">{value}</p>
</div>
</button>
);
}
function RecentDeliverableItem({ os }: { os: RecentDeliverable }) {
return (
<Link
to={`/entregaveis/`}
className="flex items-center justify-between gap-3 border-b border-border p-4 last:border-b-0 transition-colors hover:bg-bg"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-small font-semibold text-isis-blue">{os.code}</span>
<StatusBadge status={os.status} size="sm" />
</div>
<p className="mt-0.5 truncate text-body text-primary">{os.title}</p>
<p className="mt-0.5 text-[11px] text-text-muted">
{os.project.name} &middot; {os.client.name}
</p>
</div>
<span className="flex-shrink-0 text-[11px] text-text-muted">
{formatRelativeTime(os.updatedAt)}
</span>
</Link>
);
}
function ActivityItem({ activity }: { activity: RecentActivity }) {
const Icon = EVENT_TYPE_ICONS[activity.type] ?? Clock;
return (
<div className="flex gap-3 border-b border-border p-4 last:border-b-0">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-surface-subtle text-muted">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-body text-primary">{activity.title}</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-text-muted">
<Link to={`/entregaveis/`} className="font-medium text-isis-blue hover:underline">
{activity.deliverable.code}
</Link>
<span>&middot;</span>
<span>{formatDateTime(activity.createdAt)}</span>
</div>
</div>
</div>
);
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
interface MoneyCardProps {
label: string;
value: number;
icon: typeof DollarSign;
color: string;
}
function MoneyCard({ label, value, icon: Icon, color }: MoneyCardProps) {
return (
<div className="flex items-center gap-4 rounded-lg border border-border bg-card p-5">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${color}`}>
<Icon className="h-6 w-6" />
</div>
<div>
<p className="text-small text-text-secondary">{label}</p>
<p className="text-xl font-bold text-primary">{formatCurrency(value)}</p>
</div>
</div>
);
}
function TopDeliverableItem({ os }: { os: TopValueDeliverable }) {
return (
<Link
to={`/entregaveis/`}
className="flex items-center justify-between gap-3 border-b border-border p-4 last:border-b-0 transition-colors hover:bg-bg"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-small font-semibold text-isis-blue">{os.code}</span>
<StatusBadge status={os.status} size="sm" />
</div>
<p className="mt-0.5 truncate text-body text-primary">{os.title}</p>
<p className="mt-0.5 text-[11px] text-text-muted">{os.client.name}</p>
</div>
<span className="flex-shrink-0 text-small font-semibold text-primary">
{formatCurrency(os.totalValue)}
</span>
</Link>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const { isReadOnly } = useReadOnly();
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const dateFilter = useMemo(
() => (startDate && endDate ? { startDate, endDate } : {}),
[startDate, endDate],
);
const { data: summary, isLoading: loadingSummary } = useDashboardSummary(dateFilter);
const { data: billing, isLoading: loadingBilling } = useDashboardBilling(dateFilter);
const { data: recentDeliverables, isLoading: loadingDeliverables } =
useRecentDeliverable(dateFilter);
const { data: recentActivity, isLoading: loadingActivity } = useRecentActivity(dateFilter);
return (
<PageContainer>
<PageHeader title="Dashboard" subtitle="Visão geral do sistema" />
<div className="mb-6 flex items-center justify-between">
<DateRangePicker
startDate={startDate}
endDate={endDate}
onChangeStart={setStartDate}
onChangeEnd={setEndDate}
onClear={() => {
setStartDate('');
setEndDate('');
}}
/>
{!isReadOnly && (
<button
type="button"
onClick={() => navigate('/entregaveis/novo')}
className="flex items-center gap-2 rounded-lg bg-isis-blue px-4 py-2 text-small font-medium text-white transition-colors hover:bg-isis-blue/90"
>
<Plus className="h-4 w-4" />
Novo Entregável
</button>
)}
</div>
{/* Indicadores principais */}
{loadingSummary ? (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
) : summary ? (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<IndicatorCard
label="Total de Entregáveis"
value={summary.totalDeliverables}
icon={FileText}
color="bg-isis-blue/10 text-isis-blue"
onClick={() => navigate('/entregaveis')}
/>
<IndicatorCard
label="Em Execução"
value={summary.inProgress}
icon={Play}
color="bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
onClick={() => navigate(`/entregaveis?status=${DeliverableStatus.EM_EXECUCAO}`)}
/>
<IndicatorCard
label="Aguardando Validação"
value={summary.awaitingValidation}
icon={Clock}
color="bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
onClick={() =>
navigate(`/entregaveis?status=${DeliverableStatus.AGUARDANDO_VALIDACAO}`)
}
/>
<IndicatorCard
label="Criadas esta Semana"
value={summary.recentlyCreated}
icon={CalendarPlus}
color="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
onClick={() => navigate('/entregaveis')}
/>
</div>
) : null}
{/* Entregáveis por Status */}
{summary && Object.keys(summary.countByStatus).length > 0 && (
<div className="mb-6">
<h2 className="mb-3 text-body font-semibold text-primary">Entregáveis por Status</h2>
<div className="flex flex-wrap gap-2">
{Object.entries(summary.countByStatus).map(([status, count]) => (
<button
key={status}
type="button"
onClick={() => navigate(`/entregaveis?status=${status}`)}
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-small transition-colors hover:border-isis-blue/40"
>
<StatusBadge status={status as DeliverableStatus} size="sm" />
<span className="font-semibold text-primary">{count}</span>
</button>
))}
</div>
</div>
)}
{/* Faturamento */}
<div className="mb-6">
<h2 className="mb-3 text-body font-semibold text-primary">Faturamento</h2>
{loadingBilling ? (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
) : billing ? (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<MoneyCard
label="Faturado"
value={billing.totalBilled}
icon={DollarSign}
color="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
/>
<MoneyCard
label="Aguardando Pagamento"
value={billing.awaitingPayment}
icon={Clock}
color="bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
/>
<MoneyCard
label="Em Execução"
value={billing.inProgress}
icon={TrendingUp}
color="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
/>
<MoneyCard
label="Aprovadas"
value={billing.approved}
icon={CheckCircle}
color="bg-isis-blue/10 text-isis-blue"
/>
</div>
) : null}
</div>
{/* Grid: Entregáveis Recentes + Top Valor + Atividade Recente */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Entregáveis Recentes */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-body font-semibold text-primary">Entregáveis Recentes</h2>
<Link
to="/entregaveis"
className="flex items-center gap-1 text-small text-isis-blue hover:underline"
>
Ver todas <ArrowRight className="h-3 w-3" />
</Link>
</div>
{loadingDeliverables ? (
<SkeletonList rows={5} />
) : recentDeliverables && recentDeliverables.length > 0 ? (
<div className="rounded-lg border border-border bg-card">
{recentDeliverables.map((os) => (
<RecentDeliverableItem key={os.id} os={os} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-card py-12 text-text-secondary">
<FileText className="h-8 w-8" />
<p className="text-small">Nenhum entregável encontrado</p>
</div>
)}
</div>
{/* Top Entregáveis por Valor */}
<div>
<h2 className="mb-3 text-body font-semibold text-primary">Top Entregáveis por Valor</h2>
{loadingBilling ? (
<SkeletonList rows={5} />
) : billing && billing.topValueDeliverables.length > 0 ? (
<div className="rounded-lg border border-border bg-card">
{billing.topValueDeliverables.map((os) => (
<TopDeliverableItem key={os.id} os={os} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-card py-12 text-text-secondary">
<DollarSign className="h-8 w-8" />
<p className="text-small">Nenhum entregável valorado</p>
</div>
)}
</div>
{/* Atividade Recente */}
<div>
<h2 className="mb-3 text-body font-semibold text-primary">Atividade Recente</h2>
{loadingActivity ? (
<SkeletonList rows={5} />
) : recentActivity && recentActivity.length > 0 ? (
<div className="rounded-lg border border-border bg-card">
{recentActivity.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-card py-12 text-text-secondary">
<Clock className="h-8 w-8" />
<p className="text-small">Nenhuma atividade recente</p>
</div>
)}
</div>
</div>
</PageContainer>
);
}

View File

View File

@@ -0,0 +1,361 @@
import { useState, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, Trash2 } from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
DataTable,
Pagination,
Tooltip,
Badge,
} from '../../components/ui';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { useToast } from '../../components/ui/Toast';
import { useDeliverables, useDeleteDeliverable } from '../../hooks/useDeliverables';
import { useClients } from '../../hooks/useClients';
import { useProjects } from '../../hooks/useProjects';
import { useWorkOrders } from '../../hooks/useWorkOrders';
import { useReadOnly } from '../../hooks/useReadOnly';
import { useFieldVisibility } from '../../hooks/useFieldVisibility';
import { DELIVERABLE_STATUS_OPTIONS } from '../../constants/deliverable-status';
import {
DELIVERABLE_TYPE_LABELS,
DELIVERABLE_TYPE_OPTIONS,
} from '../../constants/deliverable-type';
import { StatusBadge } from '../../components/shared/StatusBadge';
import { DeliverableStatus } from '../../types/deliverable.types';
import type { DeliverableListItem, DeliverablesFilters } from '../../types/deliverable.types';
const ITEMS_PER_PAGE = 20;
export function EntregaveisListPage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const { showToast } = useToast();
const { isReadOnly } = useReadOnly();
const isVisible = useFieldVisibility();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<string>('');
const [workOrderFilter, setWorkOrderFilter] = useState<string>('');
const [clientFilter, setClientFilter] = useState<string>('');
const [projectFilter, setProjectFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [osToDelete, setOsToDelete] = useState<DeliverableListItem | null>(null);
const deleteMutation = useDeleteDeliverable();
const filters: DeliverablesFilters = useMemo(
() => ({
search: searchValue || undefined,
status: (statusFilter as DeliverableStatus) || undefined,
type: (typeFilter as DeliverablesFilters['type']) || undefined,
workOrderId: workOrderFilter || undefined,
clientId: clientFilter || undefined,
projectId: projectFilter || undefined,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, typeFilter, workOrderFilter, clientFilter, projectFilter, page],
);
const { data, isLoading } = useDeliverables(filters);
const { data: clientsData } = useClients({ page: 1, limit: 100 });
const { data: projectsData } = useProjects({ page: 1, limit: 100 });
const { data: workOrdersData } = useWorkOrders({ isActive: true, page: 1, limit: 20 });
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const clientOptions = useMemo(
() => (clientsData?.data ?? []).map((c) => ({ value: c.id, label: c.name })),
[clientsData],
);
const projectOptions = useMemo(
() => (projectsData?.data ?? []).map((p) => ({ value: p.id, label: p.name })),
[projectsData],
);
const workOrderOptions = useMemo(
() =>
(workOrdersData?.data ?? []).map((wo) => ({
value: wo.id,
label: `${wo.code}${wo.name}`,
})),
[workOrdersData],
);
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
function handleClientChange(e: React.ChangeEvent<HTMLSelectElement>) {
setClientFilter(e.target.value);
setPage(1);
}
function handleProjectChange(e: React.ChangeEvent<HTMLSelectElement>) {
setProjectFilter(e.target.value);
setPage(1);
}
function handleDelete() {
if (!osToDelete) return;
deleteMutation.mutate(osToDelete.id, {
onSuccess: () => {
showToast('Entregável removido com sucesso', 'success');
setOsToDelete(null);
},
onError: () => {
showToast('Erro ao remover entregável', 'error');
setOsToDelete(null);
},
});
}
function formatDate(dateStr: string | null) {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
function formatCurrency(value: number | null) {
if (value == null) return '—';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
const columns = useMemo(
() => [
{
key: 'code',
header: 'Código',
render: (os: DeliverableListItem) => (
<span className="text-primary font-medium">{os.code}</span>
),
},
{
key: 'title',
header: 'Título',
render: (os: DeliverableListItem) => <span className="text-primary">{os.title}</span>,
},
{
key: 'workOrder',
header: 'OS Mãe',
render: (os: DeliverableListItem) =>
os.workOrder ? (
<Link
to={`/ordens-servico/${os.workOrder.id}`}
className="text-isis-blue hover:underline"
onClick={(e) => e.stopPropagation()}
>
{os.workOrder.code}
</Link>
) : (
<span className="text-text-muted"></span>
),
},
{
key: 'type',
header: 'Tipo',
render: (os: DeliverableListItem) => (
<Badge variant="neutral">{DELIVERABLE_TYPE_LABELS[os.type] ?? os.type}</Badge>
),
},
{
key: 'status',
header: 'Status',
render: (os: DeliverableListItem) => <StatusBadge status={os.status} />,
},
...(isVisible('totalValue')
? [
{
key: 'totalValue',
header: 'Valor Total',
render: (os: DeliverableListItem) => (
<span className="text-text-secondary text-right">{formatCurrency(os.totalValue)}</span>
),
},
]
: []),
{
key: 'project',
header: 'Projeto',
render: (os: DeliverableListItem) => (
<span className="text-text-secondary">{os.project.name}</span>
),
},
{
key: 'client',
header: 'Cliente',
render: (os: DeliverableListItem) => (
<span className="text-text-secondary">{os.client.name}</span>
),
},
{
key: 'sprint',
header: 'Sprint',
render: (os: DeliverableListItem) => (
<span className="text-text-secondary">{os.sprint?.name ?? '—'}</span>
),
},
{
key: 'createdAt',
header: 'Data Criação',
render: (os: DeliverableListItem) => (
<span className="text-text-secondary">{formatDate(os.createdAt)}</span>
),
},
{
key: 'actions',
header: 'Ações',
render: (os: DeliverableListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/entregaveis/${os.id}`)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!isReadOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/entregaveis/${os.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
{!isReadOnly && os.status === DeliverableStatus.RASCUNHO && (
<Tooltip content="Remover">
<Button
variant="ghost"
size="icon"
onClick={() => setOsToDelete(os)}
className="text-danger hover:text-danger/80"
aria-label="Remover"
>
<Trash2 size={14} />
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate, isReadOnly, isVisible],
);
return (
<PageContainer>
<PageHeader
title="Entregáveis"
breadcrumbs={breadcrumbs}
actions={
!isReadOnly ? (
<Button onClick={() => navigate('/entregaveis/novo')} icon={<Plus size={16} />}>
Novo Entregável
</Button>
) : undefined
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por título ou código..."
/>
</div>
<Select
options={DELIVERABLE_STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
<Select
options={DELIVERABLE_TYPE_OPTIONS}
placeholder="Todos os tipos"
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value);
setPage(1);
}}
/>
<Select
options={workOrderOptions}
placeholder="Todas as OS"
value={workOrderFilter}
onChange={(e) => {
setWorkOrderFilter(e.target.value);
setPage(1);
}}
/>
{!isReadOnly && (
<Select
options={clientOptions}
placeholder="Todos os clientes"
value={clientFilter}
onChange={handleClientChange}
/>
)}
<Select
options={projectOptions}
placeholder="Todos os projetos"
value={projectFilter}
onChange={handleProjectChange}
/>
</div>
<DataTable<DeliverableListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum entregável encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="entregável"
itemLabelPlural="entregáveis"
onPageChange={setPage}
/>
<ConfirmDialog
open={!!osToDelete}
title="Remover entregável"
message={`Tem certeza que deseja remover o entregável "${osToDelete?.code} - ${osToDelete?.title}"?`}
confirmLabel="Remover"
variant="danger"
loading={deleteMutation.isPending}
onConfirm={handleDelete}
onCancel={() => setOsToDelete(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,362 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useCreateDeliverable } from '../../hooks/useDeliverables';
import { useWorkOrders, useWorkOrder } from '../../hooks/useWorkOrders';
import { useProjects } from '../../hooks/useProjects';
import { useSprints } from '../../hooks/useSprints';
import { DeliverableType, DELIVERABLE_TYPE_OPTIONS } from '../../constants/deliverable-type';
import { ContractItemType } from '../../types/contract-item.types';
import { useDeliverableValuePreview } from '../../hooks/useDeliverableValuePreview';
import { useDeliverableNumWeeksPreview } from '../../hooks/useDeliverableNumWeeksPreview';
import { DeliverableValuePreviewBlock } from './components/DeliverableValuePreviewBlock';
const createEntregavelSchema = z
.object({
title: z.string().min(1, 'Título é obrigatório'),
code: z.string().min(1, 'Código é obrigatório'),
workOrderId: z.string().uuid('Ordem de Serviço é obrigatória'),
type: z.enum(
[
DeliverableType.DESCOBERTA,
DeliverableType.DESIGN,
DeliverableType.ARQUITETURA,
DeliverableType.CONSTRUCAO,
DeliverableType.MANUTENCAO,
DeliverableType.LICENCA,
],
{ message: 'Tipo de entrega é obrigatório' },
),
projectId: z.string().uuid('Projeto é obrigatório'),
sprintId: z.string().uuid('Sprint é obrigatória'),
timeboxManutencao: z.string().optional(),
description: z.string().optional(),
startDate: z.string().min(1, 'Data de início é obrigatória'),
expectedEndDate: z.string().min(1, 'Data prevista de término é obrigatória'),
})
.superRefine((data, ctx) => {
if (data.type === DeliverableType.MANUTENCAO) {
const n = data.timeboxManutencao ? Number(data.timeboxManutencao) : NaN;
if (!data.timeboxManutencao || !Number.isFinite(n) || n <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['timeboxManutencao'],
message: 'Informe o time-box de manutenção',
});
}
}
})
.refine((data) => new Date(data.expectedEndDate) >= new Date(data.startDate), {
message: 'Data prevista de término deve ser igual ou posterior à data de início',
path: ['expectedEndDate'],
});
type CreateEntregavelFormData = z.infer<typeof createEntregavelSchema>;
export function EntregavelCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const preselectedWorkOrderId = searchParams.get('workOrderId') ?? '';
const createMutation = useCreateDeliverable();
const {
register,
handleSubmit,
watch,
setValue,
control,
formState: { errors, isSubmitting },
} = useForm<CreateEntregavelFormData>({
resolver: zodResolver(createEntregavelSchema),
defaultValues: { workOrderId: preselectedWorkOrderId },
});
const selectedWorkOrderId = watch('workOrderId');
const { data: workOrdersData } = useWorkOrders({
isActive: true,
page: 1,
limit: 100,
});
const { data: selectedWorkOrder } = useWorkOrder(selectedWorkOrderId ?? '');
const { data: projectsData } = useProjects({ isActive: 'true', page: 1, limit: 100 });
const { data: sprintsData } = useSprints({ isActive: 'true', page: 1, limit: 100 });
const workOrderOptions = useMemo(
() =>
(workOrdersData?.data ?? [])
.filter((wo) => wo.status !== 'CANCELADA' && wo.status !== 'TOTALMENTE_PAGA')
.map((wo) => ({ value: wo.id, label: `${wo.code}${wo.name}` })),
[workOrdersData],
);
const allowedProjectIds = useMemo(
() => new Set((selectedWorkOrder?.projects ?? []).map((p) => p.id)),
[selectedWorkOrder],
);
const projectOptions = useMemo(
() =>
(projectsData?.data ?? [])
.filter((p) => allowedProjectIds.has(p.id))
.map((p) => ({ value: p.id, label: p.name })),
[projectsData, allowedProjectIds],
);
const sprintOptions = useMemo(
() => (sprintsData?.data ?? []).map((s) => ({ value: s.id, label: s.name })),
[sprintsData],
);
const watchedType = watch('type');
const watchedStartDate = watch('startDate');
const watchedExpectedEndDate = watch('expectedEndDate');
const watchedTimeboxManutencao = watch('timeboxManutencao');
const selectedItemType = selectedWorkOrder?.contractItem?.itemType ?? null;
const allowedTypes = useMemo<DeliverableType[]>(() => {
if (selectedItemType === ContractItemType.SAAS_LICENSE) return [DeliverableType.LICENCA];
if (selectedItemType === ContractItemType.UST) {
return [
DeliverableType.DESCOBERTA,
DeliverableType.DESIGN,
DeliverableType.ARQUITETURA,
DeliverableType.CONSTRUCAO,
DeliverableType.MANUTENCAO,
];
}
return Object.values(DeliverableType);
}, [selectedItemType]);
const filteredTypeOptions = useMemo(
() =>
DELIVERABLE_TYPE_OPTIONS.filter((opt) => allowedTypes.includes(opt.value as DeliverableType)),
[allowedTypes],
);
const previewContractItem = useMemo(() => {
if (!selectedWorkOrder?.contractItem) return null;
const ci = selectedWorkOrder.contractItem;
const toNum = (v: unknown): number | null => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
return {
itemType: ci.itemType,
ustValue: toNum(ci.ustValue),
timeboxDescoberta: toNum(ci.timeboxDescoberta),
timeboxDesign: toNum(ci.timeboxDesign),
timeboxArquitetura: toNum(ci.timeboxArquitetura),
timeboxConstrucao: toNum(ci.timeboxConstrucao),
};
}, [selectedWorkOrder]);
const numWeeksPreview = useDeliverableNumWeeksPreview({
startDate: watchedStartDate,
expectedEndDate: watchedExpectedEndDate,
});
const timeboxManutencaoNumber = useMemo(() => {
if (!watchedTimeboxManutencao) return null;
const n = Number(watchedTimeboxManutencao);
return Number.isFinite(n) && n > 0 ? n : null;
}, [watchedTimeboxManutencao]);
const valuePreview = useDeliverableValuePreview({
type: watchedType ?? null,
numWeeks: numWeeksPreview,
timeboxManutencao: timeboxManutencaoNumber,
contractItem: previewContractItem,
});
useEffect(() => {
if (watchedType !== DeliverableType.MANUTENCAO) {
setValue('timeboxManutencao', '', { shouldValidate: false });
}
}, [watchedType, setValue]);
useEffect(() => {
if (watchedType && !allowedTypes.includes(watchedType)) {
setValue('type', '' as unknown as DeliverableType, { shouldValidate: true });
}
}, [allowedTypes, watchedType, setValue]);
useEffect(() => {
if (preselectedWorkOrderId) {
setValue('workOrderId', preselectedWorkOrderId, { shouldValidate: true });
}
}, [preselectedWorkOrderId, setValue]);
function handleWorkOrderChange(e: React.ChangeEvent<HTMLSelectElement>) {
register('workOrderId').onChange(e);
setValue('projectId', '' as unknown as string, { shouldValidate: false });
}
async function onSubmit(data: CreateEntregavelFormData) {
setApiError(null);
try {
const result = await createMutation.mutateAsync({
title: data.title,
code: data.code,
workOrderId: data.workOrderId,
type: data.type,
projectId: data.projectId,
sprintId: data.sprintId,
timeboxManutencao:
data.type === DeliverableType.MANUTENCAO && data.timeboxManutencao
? Number(data.timeboxManutencao)
: undefined,
description: data.description || undefined,
startDate: data.startDate,
expectedEndDate: data.expectedEndDate,
});
showToast('Entregável criado com sucesso', 'success');
navigate(`/entregaveis/${result.id}`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar entregável. Tente novamente.');
} else {
setApiError('Erro ao criar entregável. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Entregável" breadcrumbs={breadcrumbs} />
<FormCard title="Novo Entregável">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Título"
placeholder="Título do entregável"
error={errors.title?.message}
{...register('title')}
/>
<Input
label="Código"
placeholder="Código do entregável"
error={errors.code?.message}
{...register('code')}
/>
<Select
label="Ordem de Serviço"
options={workOrderOptions}
placeholder="Selecione uma OS"
error={errors.workOrderId?.message}
{...register('workOrderId')}
onChange={handleWorkOrderChange}
/>
{selectedWorkOrder && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="Contrato (herdado)"
value={selectedWorkOrder.contract.name}
readOnly
disabled
/>
<Input
label="Item de Contrato (herdado)"
value={`${selectedWorkOrder.contractItem.code}${selectedWorkOrder.contractItem.name}`}
readOnly
disabled
/>
</div>
)}
<Select
label="Tipo de Entrega"
options={filteredTypeOptions}
placeholder="Selecione o tipo"
error={errors.type?.message}
{...register('type')}
/>
<Select
label="Projeto"
options={projectOptions}
placeholder={
selectedWorkOrderId
? projectOptions.length > 0
? 'Selecione um projeto'
: 'OS sem projetos disponíveis'
: 'Selecione uma OS primeiro'
}
error={errors.projectId?.message}
disabled={!selectedWorkOrderId || projectOptions.length === 0}
{...register('projectId')}
/>
<Select
label="Sprint"
options={sprintOptions}
placeholder="Selecione uma sprint"
error={errors.sprintId?.message}
{...register('sprintId')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField
name="expectedEndDate"
control={control}
label="Data Prevista de Término"
/>
<div className="rounded border border-border-soft bg-surface-muted px-3 py-2 text-small text-text-muted">
de semanas calculado:{' '}
<span className="font-medium text-primary">{numWeeksPreview ?? '—'}</span>
</div>
{watchedType === DeliverableType.MANUTENCAO && (
<Input
label="Time-box (h/semana)"
type="number"
min={0.01}
step={0.01}
placeholder="Ex.: 20"
error={errors.timeboxManutencao?.message}
{...register('timeboxManutencao')}
/>
)}
<DeliverableValuePreviewBlock preview={valuePreview} type={watchedType ?? null} />
<Input
label="Descrição"
placeholder="Descrição do entregável"
error={errors.description?.message}
{...register('description')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/entregaveis')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,292 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Edit,
RefreshCw,
Users,
ClipboardList,
FileText,
LayoutList,
Clock,
MessageSquare,
Info,
FolderOpen,
Package,
Zap,
Calendar,
CalendarClock,
Layers,
Briefcase,
Tag,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { DELIVERABLE_TYPE_LABELS } from '../../constants/deliverable-type';
import { PageContainer, PageHeader } from '../../components/layout';
import { Button } from '../../components/ui/Button';
import { Tabs, type TabItem } from '../../components/ui/Tabs';
import { StatusBadge } from '../../components/shared/StatusBadge';
import { StatusTransitionModal } from '../../components/shared/StatusTransitionModal';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useReadOnly } from '../../hooks/useReadOnly';
import { useAuth } from '../../modules/auth/useAuth';
import { useDeliverable } from '../../hooks/useDeliverables';
import { EntregavelSummaryTab } from './components/EntregavelSummaryTab';
import { EntregavelTeamTab } from './components/EntregavelTeamTab';
import { EntregavelBacklogTab } from './components/EntregavelBacklogTab';
import { EntregavelTimelineTab } from './components/EntregavelTimelineTab';
import { EntregavelNotesTab } from './components/EntregavelNotesTab';
import { EntregavelAllocationTab } from './components/EntregavelAllocationTab';
import { EntregavelValuationCard } from './components/EntregavelValuationCard';
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function EntregavelDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: serviceOrder, isLoading, isError } = useDeliverable(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':id': serviceOrder?.code ?? id ?? '' });
const { isReadOnly } = useReadOnly();
const { user } = useAuth();
const isClient = !!user && (['PO', 'FISCAL_CONTRATO', 'GESTOR_CONTRATO'] as const).includes(user.role as 'PO' | 'FISCAL_CONTRATO' | 'GESTOR_CONTRATO');
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
if (isLoading) {
return (
<PageContainer>
<div className="flex items-center justify-center py-20">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !serviceOrder) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-20 text-text-secondary">
<Info className="h-10 w-10" />
<p className="text-lg font-medium">Entregável não encontrado</p>
<Button variant="secondary" onClick={() => navigate('/entregaveis')}>
Voltar para lista
</Button>
</div>
</PageContainer>
);
}
const tabs: TabItem[] = [
{ id: 'resumo', label: 'Resumo', icon: <FileText className="h-4 w-4" /> },
{
id: 'alocacao',
label: 'Alocação',
icon: <Layers className="h-4 w-4" />,
count: serviceOrder._count.allocations,
},
...(!isClient
? [
{
id: 'equipe',
label: 'Equipe',
icon: <Users className="h-4 w-4" />,
count: serviceOrder._count.assignments,
},
]
: []),
{
id: 'backlog',
label: 'Backlog',
icon: <LayoutList className="h-4 w-4" />,
count: serviceOrder._count.backlogItems,
},
{ id: 'timeline', label: 'Timeline', icon: <Clock className="h-4 w-4" /> },
{
id: 'anotacoes',
label: 'Anotações',
icon: <MessageSquare className="h-4 w-4" />,
count: serviceOrder._count.notes,
},
];
return (
<PageContainer>
<PageHeader
title={serviceOrder.title}
subtitle={serviceOrder.code}
breadcrumbs={breadcrumbs}
/>
{/* Cabeçalho do entregável */}
<div className="mb-6 rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:gap-6">
{/* Zona 1: Identidade */}
<div className="flex min-w-0 flex-shrink-0 flex-col gap-1">
<h1 className="text-2xl font-semibold text-primary">{serviceOrder.code}</h1>
<p className="text-small text-text-muted">Entregável</p>
<div className="mt-1">
<StatusBadge status={serviceOrder.status} size="lg" />
</div>
</div>
{/* Separador vertical (desktop) */}
<div className="hidden h-16 w-px bg-border lg:block" />
{/* Zona 2: Metadados */}
<div className="grid flex-1 grid-cols-2 gap-x-6 gap-y-3 lg:grid-cols-3">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<Briefcase className="h-3 w-3" />
OS Mãe
</span>
{serviceOrder.workOrder ? (
<Link
to={`/ordens-servico/${serviceOrder.workOrder.id}`}
className="text-small font-medium text-isis-blue hover:underline"
>
{serviceOrder.workOrder.code}
</Link>
) : (
<span className="text-small font-medium text-text-muted"></span>
)}
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<Tag className="h-3 w-3" />
Tipo
</span>
<span className="text-small font-medium text-primary">
{DELIVERABLE_TYPE_LABELS[serviceOrder.type] ?? serviceOrder.type}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<FolderOpen className="h-3 w-3" />
Projeto
</span>
<span className="text-small font-medium text-primary">
{serviceOrder.project.name}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<Package className="h-3 w-3" />
Item de Contrato
</span>
<span className="text-small font-medium text-primary">
{serviceOrder.contractItem
? `${serviceOrder.contractItem.code}${serviceOrder.contractItem.name}`
: '—'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<Zap className="h-3 w-3" />
Sprint
</span>
<span className="text-small font-medium text-primary">
{serviceOrder.sprint?.name ?? '—'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<Calendar className="h-3 w-3" />
Início
</span>
<span className="text-small font-medium text-primary">
{formatDate(serviceOrder.startDate)}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-text-muted">
<CalendarClock className="h-3 w-3" />
Previsão
</span>
<span className="text-small font-medium text-primary">
{formatDate(serviceOrder.expectedEndDate)}
</span>
</div>
</div>
{/* Zona 3: Ações */}
{!isReadOnly && (
<div className="flex flex-shrink-0 items-center gap-2 lg:ml-auto">
<Button
variant="ghost"
size="sm"
icon={<Edit className="h-4 w-4" />}
onClick={() => navigate(`/entregaveis/${id}/editar`)}
>
Editar
</Button>
<Button
variant="primary"
size="sm"
icon={<RefreshCw className="h-4 w-4" />}
onClick={() => setIsStatusModalOpen(true)}
>
Mudar Status
</Button>
</div>
)}
</div>
{/* Barra de indicadores */}
<div className="flex items-center gap-4 border-t border-border px-5 py-2.5 text-small text-text-muted">
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
{serviceOrder._count.assignments} profissionais
</span>
<span className="flex items-center gap-1.5">
<ClipboardList className="h-3.5 w-3.5" />
{serviceOrder._count.backlogItems} itens
</span>
</div>
</div>
{/* Card de Valoração */}
<div className="mb-6">
<EntregavelValuationCard deliverable={serviceOrder} />
</div>
{/* Abas */}
<Tabs tabs={tabs} defaultTab="resumo">
{(activeTabId) => (
<div className="pt-4">
{activeTabId === 'resumo' && <EntregavelSummaryTab serviceOrder={serviceOrder} />}
{activeTabId === 'alocacao' && (
<EntregavelAllocationTab
serviceOrderId={id ?? ''}
clientId={serviceOrder.client.id}
readOnly={isReadOnly}
itemType={serviceOrder.contractItem?.itemType ?? null}
/>
)}
{activeTabId === 'equipe' && (
<EntregavelTeamTab serviceOrderId={id ?? ''} readOnly={isReadOnly} />
)}
{activeTabId === 'backlog' && (
<EntregavelBacklogTab serviceOrderId={id ?? ''} readOnly={isReadOnly} />
)}
{activeTabId === 'timeline' && <EntregavelTimelineTab serviceOrderId={id ?? ''} />}
{activeTabId === 'anotacoes' && (
<EntregavelNotesTab serviceOrderId={id ?? ''} readOnly={isReadOnly} />
)}
</div>
)}
</Tabs>
{/* Modal de transição de status */}
<StatusTransitionModal
isOpen={isStatusModalOpen}
onClose={() => setIsStatusModalOpen(false)}
serviceOrderId={id ?? ''}
currentStatus={serviceOrder.status}
sprintLinked={!!serviceOrder.sprint}
workOrderId={serviceOrder.workOrder?.id ?? null}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,383 @@
import { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useDeliverable, useUpdateDeliverable } from '../../hooks/useDeliverables';
import { useWorkOrder } from '../../hooks/useWorkOrders';
import { useProjects } from '../../hooks/useProjects';
import { useSprints } from '../../hooks/useSprints';
import { DeliverableType, DELIVERABLE_TYPE_OPTIONS } from '../../constants/deliverable-type';
import { ContractItemType } from '../../types/contract-item.types';
import { useDeliverableValuePreview } from '../../hooks/useDeliverableValuePreview';
import { useDeliverableNumWeeksPreview } from '../../hooks/useDeliverableNumWeeksPreview';
import { DeliverableValuePreviewBlock } from './components/DeliverableValuePreviewBlock';
const editEntregavelSchema = z
.object({
title: z.string().min(1, 'Título é obrigatório'),
code: z.string().min(1, 'Código é obrigatório'),
type: z.enum(
[
DeliverableType.DESCOBERTA,
DeliverableType.DESIGN,
DeliverableType.ARQUITETURA,
DeliverableType.CONSTRUCAO,
DeliverableType.MANUTENCAO,
DeliverableType.LICENCA,
],
{ message: 'Tipo de entrega é obrigatório' },
),
projectId: z.string().uuid('Projeto é obrigatório'),
sprintId: z.string().uuid('Sprint é obrigatória'),
timeboxManutencao: z.string().optional(),
description: z.string().optional(),
startDate: z.string().min(1, 'Data de início é obrigatória'),
expectedEndDate: z.string().min(1, 'Data prevista de término é obrigatória'),
})
.superRefine((data, ctx) => {
if (data.type === DeliverableType.MANUTENCAO) {
const n = data.timeboxManutencao ? Number(data.timeboxManutencao) : NaN;
if (!data.timeboxManutencao || !Number.isFinite(n) || n <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['timeboxManutencao'],
message: 'Informe o time-box de manutenção',
});
}
}
})
.refine((data) => new Date(data.expectedEndDate) >= new Date(data.startDate), {
message: 'Data prevista de término deve ser igual ou posterior à data de início',
path: ['expectedEndDate'],
});
type EditEntregavelFormData = z.infer<typeof editEntregavelSchema>;
function toDateInputValue(dateStr: string | null): string {
if (!dateStr) return '';
return dateStr.slice(0, 10);
}
export function EntregavelEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: serviceOrder, isLoading, isError } = useDeliverable(id ?? '');
const breadcrumbs = useBreadcrumbs();
const updateMutation = useUpdateDeliverable();
const workOrderId = serviceOrder?.workOrderId ?? '';
const { data: workOrder, isLoading: isLoadingWorkOrder } = useWorkOrder(workOrderId);
const { data: projectsData, isLoading: isLoadingProjects } = useProjects({
isActive: 'true',
page: 1,
limit: 100,
});
const { data: sprintsData, isLoading: isLoadingSprints } = useSprints({
isActive: 'true',
page: 1,
limit: 100,
});
const allOptionsLoaded =
!isLoadingProjects && !isLoadingSprints && (!workOrderId || !isLoadingWorkOrder);
const {
register,
handleSubmit,
reset,
setValue,
watch,
control,
formState: { errors, isSubmitting },
} = useForm<EditEntregavelFormData>({
resolver: zodResolver(editEntregavelSchema),
});
useEffect(() => {
if (serviceOrder && allOptionsLoaded) {
reset({
title: serviceOrder.title,
code: serviceOrder.code,
type: serviceOrder.type,
projectId: serviceOrder.project.id,
sprintId: serviceOrder.sprint?.id ?? '',
timeboxManutencao:
serviceOrder.timeboxManutencao != null ? String(serviceOrder.timeboxManutencao) : '',
description: serviceOrder.description ?? '',
startDate: toDateInputValue(serviceOrder.startDate),
expectedEndDate: toDateInputValue(serviceOrder.expectedEndDate),
});
}
}, [serviceOrder, allOptionsLoaded, reset]);
const watchedType = watch('type');
const watchedStartDate = watch('startDate');
const watchedExpectedEndDate = watch('expectedEndDate');
const watchedTimeboxManutencao = watch('timeboxManutencao');
const selectedItemType = workOrder?.contractItem?.itemType ?? null;
const allowedTypes = useMemo<DeliverableType[]>(() => {
if (selectedItemType === ContractItemType.SAAS_LICENSE) return [DeliverableType.LICENCA];
if (selectedItemType === ContractItemType.UST) {
return [
DeliverableType.DESCOBERTA,
DeliverableType.DESIGN,
DeliverableType.ARQUITETURA,
DeliverableType.CONSTRUCAO,
DeliverableType.MANUTENCAO,
];
}
return Object.values(DeliverableType);
}, [selectedItemType]);
const filteredTypeOptions = useMemo(
() =>
DELIVERABLE_TYPE_OPTIONS.filter((opt) => allowedTypes.includes(opt.value as DeliverableType)),
[allowedTypes],
);
const previewContractItem = useMemo(() => {
if (!workOrder?.contractItem) return null;
const ci = workOrder.contractItem;
const toNum = (v: unknown): number | null => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
return {
itemType: ci.itemType,
ustValue: toNum(ci.ustValue),
timeboxDescoberta: toNum(ci.timeboxDescoberta),
timeboxDesign: toNum(ci.timeboxDesign),
timeboxArquitetura: toNum(ci.timeboxArquitetura),
timeboxConstrucao: toNum(ci.timeboxConstrucao),
};
}, [workOrder]);
const numWeeksPreview = useDeliverableNumWeeksPreview({
startDate: watchedStartDate,
expectedEndDate: watchedExpectedEndDate,
});
const timeboxManutencaoNumber = useMemo(() => {
if (!watchedTimeboxManutencao) return null;
const n = Number(watchedTimeboxManutencao);
return Number.isFinite(n) && n > 0 ? n : null;
}, [watchedTimeboxManutencao]);
const valuePreview = useDeliverableValuePreview({
type: watchedType ?? null,
numWeeks: numWeeksPreview,
timeboxManutencao: timeboxManutencaoNumber,
contractItem: previewContractItem,
});
useEffect(() => {
if (watchedType && watchedType !== DeliverableType.MANUTENCAO) {
setValue('timeboxManutencao', '', { shouldValidate: false });
}
}, [watchedType, setValue]);
const allowedProjectIds = useMemo(
() => new Set((workOrder?.projects ?? []).map((p) => p.id)),
[workOrder],
);
const projectOptions = useMemo(
() =>
(projectsData?.data ?? [])
.filter((p) => allowedProjectIds.has(p.id))
.map((p) => ({ value: p.id, label: p.name })),
[projectsData, allowedProjectIds],
);
const sprintOptions = useMemo(
() => (sprintsData?.data ?? []).map((s) => ({ value: s.id, label: s.name })),
[sprintsData],
);
async function onSubmit(data: EditEntregavelFormData) {
if (!id) return;
setApiError(null);
try {
await updateMutation.mutateAsync({
id,
data: {
title: data.title,
code: data.code,
type: data.type,
projectId: data.projectId,
sprintId: data.sprintId,
timeboxManutencao:
data.type === DeliverableType.MANUTENCAO && data.timeboxManutencao
? Number(data.timeboxManutencao)
: null,
description: data.description || undefined,
startDate: data.startDate,
expectedEndDate: data.expectedEndDate,
},
});
showToast('Entregável atualizado com sucesso', 'success');
navigate(`/entregaveis/${id}`);
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 404) {
setApiError('Entregável não encontrado');
} else {
setApiError(
err.response?.data?.message ?? 'Erro ao atualizar entregável. Tente novamente.',
);
}
} else {
setApiError('Erro ao atualizar entregável. Tente novamente.');
}
}
}
if (isLoading || !allOptionsLoaded) {
return (
<PageContainer>
<PageHeader title="Editar Entregável" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !serviceOrder) {
return (
<PageContainer>
<PageHeader title="Editar Entregável" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Entregável não encontrado</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Entregável" breadcrumbs={breadcrumbs} />
<FormCard title={`Editar Entregável — ${serviceOrder.code}`}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<div>
<label className="block text-small font-medium text-primary mb-1">
Ordem de Serviço
</label>
{serviceOrder.workOrder ? (
<Link
to={`/ordens-servico/${serviceOrder.workOrder.id}`}
className="text-isis-blue hover:underline"
>
{serviceOrder.workOrder.code} {serviceOrder.workOrder.name}
</Link>
) : (
<span className="text-text-muted"></span>
)}
<p className="mt-1 text-small text-text-muted">
A OS Mãe não pode ser alterada após a criação do entregável.
</p>
</div>
<Input
label="Título"
placeholder="Título do entregável"
error={errors.title?.message}
{...register('title')}
/>
<Input
label="Código"
placeholder="Código do entregável"
error={errors.code?.message}
{...register('code')}
/>
<Select
label="Tipo de Entrega"
options={filteredTypeOptions}
placeholder="Selecione o tipo"
error={errors.type?.message}
{...register('type')}
/>
<Select
label="Projeto"
options={projectOptions}
placeholder="Selecione um projeto"
error={errors.projectId?.message}
{...register('projectId')}
/>
<Select
label="Sprint"
options={sprintOptions}
placeholder="Selecione uma sprint"
error={errors.sprintId?.message}
{...register('sprintId')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField
name="expectedEndDate"
control={control}
label="Data Prevista de Término"
/>
<div className="rounded border border-border-soft bg-surface-muted px-3 py-2 text-small text-text-muted">
de semanas calculado:{' '}
<span className="font-medium text-primary">{numWeeksPreview ?? '—'}</span>
</div>
{watchedType === DeliverableType.MANUTENCAO && (
<Input
label="Time-box (h/semana)"
type="number"
min={0.01}
step={0.01}
placeholder="Ex.: 20"
error={errors.timeboxManutencao?.message}
{...register('timeboxManutencao')}
/>
)}
<DeliverableValuePreviewBlock preview={valuePreview} type={watchedType ?? null} />
<Input
label="Descrição"
placeholder="Descrição do entregável"
error={errors.description?.message}
{...register('description')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
type="button"
onClick={() => navigate(`/entregaveis/${id}`)}
>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,132 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EntregaveisListPage } from '../EntregaveisListPage';
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
import type { UserRole } from '../../../types/auth.types';
const mockDeliverables = {
data: [
{
id: 'del-1',
code: 'ENT-001',
title: 'Entregável Teste',
status: 'EM_EXECUCAO',
type: 'DESENVOLVIMENTO',
workOrderId: 'wo-1',
workOrder: null,
client: { id: 'c1', name: 'Empresa X' },
contract: { id: 'ct1', name: 'Contrato 1' },
project: { id: 'p1', name: 'Projeto Alpha' },
sprint: null,
contractItem: { id: 'ci1', name: 'Item 1', code: 'CI-001', itemType: 'UST', ustValue: null, timeboxDescoberta: null, timeboxDesign: null, timeboxArquitetura: null, timeboxConstrucao: null, timeboxManutencao: null },
ustQuantity: 10,
totalValue: 50000,
numWeeks: 4,
startDate: '2026-01-01',
expectedEndDate: '2026-03-01',
isActive: true,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
],
total: 1,
page: 1,
limit: 20,
};
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverables: vi.fn(() => ({ data: mockDeliverables, isLoading: false })),
useDeleteDeliverable: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}));
vi.mock('../../../hooks/useClients', () => ({
useClients: vi.fn(() => ({ data: { data: [], total: 0, page: 1, limit: 100 } })),
}));
vi.mock('../../../hooks/useProjects', () => ({
useProjects: vi.fn(() => ({ data: { data: [], total: 0, page: 1, limit: 100 } })),
}));
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrders: vi.fn(() => ({ data: { data: [], total: 0, page: 1, limit: 20 } })),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: vi.fn(() => []),
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: vi.fn(() => ({ showToast: vi.fn() })),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: vi.fn(() => ({ isReadOnly: false, clientId: null })),
}));
const mockUseAuth = vi.fn();
vi.mock('../../../modules/auth', () => ({
useAuth: (...args: unknown[]) => mockUseAuth(...args),
}));
function makeUser(role: UserRole) {
return { id: '1', name: 'User', email: 'user@test.com', role };
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PageTitleProvider>
<EntregaveisListPage />
</PageTitleProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
mockUseAuth.mockReturnValue({
user: makeUser('ADMIN'),
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
});
});
describe('EntregaveisListPage — field visibility', () => {
it('ADMIN sees the Valor Total column header', async () => {
mockUseAuth.mockReturnValue({ user: makeUser('ADMIN'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.getByText('Valor Total')).toBeInTheDocument();
});
it('GESTOR_PROJETOS does not see the Valor Total column header', () => {
mockUseAuth.mockReturnValue({ user: makeUser('GESTOR_PROJETOS'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor Total')).not.toBeInTheDocument();
});
it('PO does not see the Valor Total column header', () => {
mockUseAuth.mockReturnValue({ user: makeUser('PO'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor Total')).not.toBeInTheDocument();
});
it('FISCAL_CONTRATO does not see the Valor Total column header', () => {
mockUseAuth.mockReturnValue({ user: makeUser('FISCAL_CONTRATO'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor Total')).not.toBeInTheDocument();
});
it('renders non-financial columns for all roles', () => {
mockUseAuth.mockReturnValue({ user: makeUser('PO'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.getByText('Código')).toBeInTheDocument();
expect(screen.getByText('Título')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,118 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EntregavelCreatePage } from '../EntregavelCreatePage';
import { ToastProvider } from '../../../components/ui/Toast';
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
import { ContractItemType } from '../../../types/contract-item.types';
const mockUseWorkOrder = vi.fn();
const mockUseWorkOrders = vi.fn();
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrders: () => mockUseWorkOrders(),
useWorkOrder: () => mockUseWorkOrder(),
}));
vi.mock('../../../hooks/useDeliverables', () => ({
useCreateDeliverable: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock('../../../hooks/useProjects', () => ({
useProjects: () => ({ data: { data: [] } }),
}));
vi.mock('../../../hooks/useSprints', () => ({
useSprints: () => ({ data: { data: [] } }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [],
}));
const buildWorkOrder = (itemType: ContractItemType | null) => ({
id: 'wo-1',
code: 'OS-001',
name: 'OS Teste',
status: 'EMITIDA',
contract: { id: 'c-1', name: 'Contrato' },
contractItem: itemType
? {
id: 'ci-1',
code: 'IC-001',
name: 'Item',
itemType,
ustValue: 500,
timeboxDescoberta: 40,
timeboxDesign: 40,
timeboxArquitetura: 40,
timeboxConstrucao: 80,
}
: null,
projects: [],
});
function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<PageTitleProvider>
<ToastProvider>
<MemoryRouter initialEntries={['/entregaveis/novo?workOrderId=wo-1']}>
<EntregavelCreatePage />
</MemoryRouter>
</ToastProvider>
</PageTitleProvider>
</QueryClientProvider>,
);
}
describe('EntregavelCreatePage — filtragem Tipo × itemType (NBC-021)', () => {
it('itemType=SAAS_LICENSE: select Tipo só apresenta opção Licença', () => {
mockUseWorkOrders.mockReturnValue({
data: { data: [buildWorkOrder(ContractItemType.SAAS_LICENSE)] },
});
mockUseWorkOrder.mockReturnValue({ data: buildWorkOrder(ContractItemType.SAAS_LICENSE) });
renderPage();
const select = document.querySelector('select[name="type"]') as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text);
expect(optionTexts).toContain('Licença');
expect(optionTexts).not.toContain('Construção');
expect(optionTexts).not.toContain('Descoberta');
expect(optionTexts).not.toContain('Manutenção');
});
it('itemType=UST: select apresenta todos os tipos exceto Licença', () => {
mockUseWorkOrders.mockReturnValue({
data: { data: [buildWorkOrder(ContractItemType.UST)] },
});
mockUseWorkOrder.mockReturnValue({ data: buildWorkOrder(ContractItemType.UST) });
renderPage();
const select = document.querySelector('select[name="type"]') as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text);
expect(optionTexts).toContain('Descoberta');
expect(optionTexts).toContain('Design');
expect(optionTexts).toContain('Arquitetura');
expect(optionTexts).toContain('Construção');
expect(optionTexts).toContain('Manutenção');
expect(optionTexts).not.toContain('Licença');
});
it('sem OS selecionada: select exibe todos os tipos', () => {
mockUseWorkOrders.mockReturnValue({ data: { data: [] } });
mockUseWorkOrder.mockReturnValue({ data: undefined });
renderPage();
const select = document.querySelector('select[name="type"]') as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text);
expect(optionTexts).toContain('Descoberta');
expect(optionTexts).toContain('Licença');
expect(optionTexts).toContain('Manutenção');
});
});

View File

@@ -0,0 +1,211 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EntregavelDetailPage } from '../EntregavelDetailPage';
import { DeliverableStatus } from '../../../types/deliverable.types';
const mockDeliverable = {
id: 'deliverable-id-1',
code: 'ENT-001',
title: 'Entregável de Teste',
description: 'Descrição do entregável',
status: DeliverableStatus.EM_EXECUCAO,
client: { id: 'c1', name: 'Cliente Teste' },
contract: { id: 'ct1', name: 'Contrato Teste' },
project: { id: 'p1', name: 'Projeto Alpha' },
sprint: { id: 's1', name: 'Sprint 1' },
startDate: '2026-01-15T00:00:00.000Z',
expectedEndDate: '2026-03-15T00:00:00.000Z',
createdAt: '2026-01-10T00:00:00.000Z',
updatedAt: '2026-02-20T10:00:00.000Z',
isActive: true,
createdBy: 'user-1',
updatedBy: 'user-1',
_count: { assignments: 3, backlogItems: 5, notes: 2 },
statusHistory: [
{
previousStatus: DeliverableStatus.EMITIDA,
newStatus: DeliverableStatus.EM_EXECUCAO,
createdAt: '2026-02-01T00:00:00.000Z',
},
],
};
const mockUseDeliverable = vi.fn();
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverable: (...args: unknown[]) => mockUseDeliverable(...args),
useAllowedTransitions: () => ({ data: [], isLoading: false }),
useChangeStatus: () => ({ mutate: vi.fn(), isPending: false }),
useStatusHistory: () => ({ data: [], isLoading: false }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [
{ label: 'Dashboard', to: '/' },
{ label: 'Entregáveis', to: '/entregaveis' },
],
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: () => ({ isReadOnly: false, clientId: null }),
}));
vi.mock('../../../modules/auth/useAuth', () => ({
useAuth: () => ({
user: { id: 'u1', name: 'Admin', email: 'admin@test.com', role: 'ADMIN' },
token: 'token',
isAuthenticated: true,
}),
}));
function renderPage(id = 'deliverable-id-1') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[`/entregaveis/${id}`]}>
<Routes>
<Route path="/entregaveis/:id" element={<EntregavelDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('EntregavelDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('exibe loading state enquanto carrega', () => {
mockUseDeliverable.mockReturnValue({ data: undefined, isLoading: true, isError: false });
renderPage();
expect(screen.queryByText('ENT-001')).not.toBeInTheDocument();
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('exibe erro 404 para entregável inexistente', () => {
mockUseDeliverable.mockReturnValue({ data: undefined, isLoading: false, isError: true });
renderPage();
expect(screen.getByText('Entregável não encontrado')).toBeInTheDocument();
expect(screen.getByText('Voltar para lista')).toBeInTheDocument();
});
it('carrega e exibe dados do entregável corretamente', () => {
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
expect(screen.getAllByText('ENT-001').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Entregável de Teste').length).toBeGreaterThanOrEqual(1);
});
it('cabeçalho exibe código, título, status, projeto, datas', () => {
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
expect(screen.getAllByText('ENT-001').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Entregável de Teste').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Projeto Alpha').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Sprint 1').length).toBeGreaterThanOrEqual(1);
});
it('barra de ações exibe botões e indicadores', () => {
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
expect(screen.getByText('Editar')).toBeInTheDocument();
expect(screen.getByText('Mudar Status')).toBeInTheDocument();
expect(screen.getByText('3 profissionais')).toBeInTheDocument();
expect(screen.getByText('5 itens')).toBeInTheDocument();
});
it('tabs navegam entre abas', async () => {
const user = userEvent.setup();
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
expect(screen.getByRole('tab', { name: /Resumo/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Equipe/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Backlog/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Timeline/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Anotações/ })).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: /Equipe/ }));
expect(screen.getByRole('tab', { name: /Equipe/ })).toHaveAttribute('aria-selected', 'true');
});
it('modal de mudança de status abre ao clicar no botão "Mudar Status"', async () => {
const user = userEvent.setup();
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
await user.click(screen.getByText('Mudar Status'));
expect(screen.getByText('Alterar Status')).toBeInTheDocument();
expect(screen.getByText(/Status atual:/)).toBeInTheDocument();
});
it('modal recebe props corretas (serviceOrderId, currentStatus)', async () => {
const user = userEvent.setup();
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
await user.click(screen.getByText('Mudar Status'));
// O modal exibe o status atual via StatusBadge — confirma que currentStatus foi passado
const modalContent = screen.getByText(/Status atual:/);
expect(modalContent).toBeInTheDocument();
});
it('modal fecha ao clicar em Cancelar', async () => {
const user = userEvent.setup();
mockUseDeliverable.mockReturnValue({
data: mockDeliverable,
isLoading: false,
isError: false,
});
renderPage();
await user.click(screen.getByText('Mudar Status'));
expect(screen.getByText('Alterar Status')).toBeInTheDocument();
await user.click(screen.getByText('Cancelar'));
expect(screen.queryByText('Alterar Status')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,195 @@
import { useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createPortal } from 'react-dom';
import { Button, Input, Select, DatePickerField } from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { useCreateAssignment, useUpdateAssignment } from '../../../hooks/useAssignments';
import { useProfessionals } from '../../../hooks/useProfessionals';
import type { AssignmentListItem } from '../../../types/deliverable.types';
const assignmentSchema = z.object({
professionalId: z.string().min(1, 'Profissional é obrigatório'),
role: z.string().min(1, 'Papel no entregável é obrigatório'),
startDate: z.string().optional(),
endDate: z.string().optional(),
observation: z.string().optional(),
});
type AssignmentFormData = z.infer<typeof assignmentSchema>;
interface AssignmentModalProps {
isOpen: boolean;
onClose: () => void;
serviceOrderId: string;
assignment?: AssignmentListItem;
}
export function AssignmentModal({
isOpen,
onClose,
serviceOrderId,
assignment,
}: AssignmentModalProps) {
const isEditing = !!assignment;
const { showToast } = useToast();
const createAssignment = useCreateAssignment();
const updateAssignment = useUpdateAssignment();
const { data: professionalsData } = useProfessionals({
isActive: 'true',
page: 1,
limit: 100,
});
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<AssignmentFormData>({
resolver: zodResolver(assignmentSchema),
});
useEffect(() => {
if (isOpen) {
if (assignment) {
reset({
professionalId: assignment.professional.id,
role: assignment.role ?? '',
startDate: assignment.startDate?.split('T')[0] ?? '',
endDate: assignment.endDate?.split('T')[0] ?? '',
observation: assignment.observation ?? '',
});
} else {
reset({
professionalId: '',
role: '',
startDate: '',
endDate: '',
observation: '',
});
}
}
}, [isOpen, assignment, reset]);
const handleClose = useCallback(() => {
if (isSubmitting) return;
onClose();
}, [onClose, isSubmitting]);
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]);
async function onSubmit(data: AssignmentFormData) {
try {
if (isEditing) {
await updateAssignment.mutateAsync({
osId: serviceOrderId,
assignmentId: assignment.id,
data: {
role: data.role,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
observation: data.observation || undefined,
},
});
showToast('Alocação atualizada com sucesso', 'success');
} else {
await createAssignment.mutateAsync({
osId: serviceOrderId,
data: {
professionalId: data.professionalId,
role: data.role,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
observation: data.observation || undefined,
},
});
showToast('Profissional alocado com sucesso', 'success');
}
handleClose();
} catch {
showToast(isEditing ? 'Erro ao atualizar alocação' : 'Erro ao alocar profissional', 'error');
}
}
const professionalOptions = (professionalsData?.data ?? []).map((p) => ({
value: p.id,
label: p.role ? `${p.name}${p.role}` : p.name,
}));
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-4 text-lg font-semibold text-primary">
{isEditing ? 'Editar Alocação' : 'Adicionar Profissional'}
</h2>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<Select
label="Profissional"
placeholder="Selecione um profissional"
options={professionalOptions}
error={errors.professionalId?.message}
disabled={isEditing}
{...register('professionalId')}
/>
<Input
label="Papel no entregável"
placeholder="Ex: Enfermeiro, Coordenador"
error={errors.role?.message}
{...register('role')}
/>
<div className="grid grid-cols-2 gap-3">
<DatePickerField name="startDate" control={control} label="Data início" />
<DatePickerField name="endDate" control={control} label="Data fim" />
</div>
<div>
<label className="mb-1 block text-small font-medium text-primary">Observação</label>
<textarea
placeholder="Observações sobre a alocação..."
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"
{...register('observation')}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createPortal } from 'react-dom';
import { Button, Input } from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { useCreateBacklogItem, useUpdateBacklogItem } from '../../../hooks/useBacklog';
import type { BacklogItem } from '../../../types/deliverable.types';
const backlogItemSchema = z.object({
title: z.string().min(1, 'Título é obrigatório'),
description: z.string().optional(),
acceptanceCriteria: z.string().optional(),
});
type BacklogItemFormData = z.infer<typeof backlogItemSchema>;
interface BacklogItemModalProps {
isOpen: boolean;
onClose: () => void;
serviceOrderId: string;
item?: BacklogItem;
}
export function BacklogItemModal({ isOpen, onClose, serviceOrderId, item }: BacklogItemModalProps) {
const isEditing = !!item;
const { showToast } = useToast();
const createItem = useCreateBacklogItem();
const updateItem = useUpdateBacklogItem();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<BacklogItemFormData>({
resolver: zodResolver(backlogItemSchema),
});
useEffect(() => {
if (isOpen) {
if (item) {
reset({
title: item.title,
description: item.description ?? '',
acceptanceCriteria: item.acceptanceCriteria ?? '',
});
} else {
reset({ title: '', description: '', acceptanceCriteria: '' });
}
}
}, [isOpen, item, reset]);
const handleClose = useCallback(() => {
if (isSubmitting) return;
onClose();
}, [onClose, isSubmitting]);
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]);
async function onSubmit(data: BacklogItemFormData) {
try {
if (isEditing) {
await updateItem.mutateAsync({
osId: serviceOrderId,
itemId: item.id,
data: {
title: data.title,
description: data.description || undefined,
acceptanceCriteria: data.acceptanceCriteria || undefined,
},
});
showToast('Item atualizado com sucesso', 'success');
} else {
await createItem.mutateAsync({
osId: serviceOrderId,
data: {
title: data.title,
description: data.description || undefined,
acceptanceCriteria: data.acceptanceCriteria || undefined,
},
});
showToast('Item adicionado com sucesso', 'success');
}
handleClose();
} catch {
showToast(isEditing ? 'Erro ao atualizar item' : 'Erro ao adicionar item', '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-4 text-lg font-semibold text-primary">
{isEditing ? 'Editar Item de Backlog' : 'Adicionar Item de Backlog'}
</h2>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<Input
label="Título"
placeholder="Título do item"
error={errors.title?.message}
{...register('title')}
/>
<div>
<label className="mb-1 block text-small font-medium text-primary">Descrição</label>
<textarea
placeholder="Descrição do item..."
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"
{...register('description')}
/>
</div>
<div>
<label className="mb-1 block text-small font-medium text-primary">
Critério de Aceite
</label>
<textarea
placeholder="Critério de aceite do item..."
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"
{...register('acceptanceCriteria')}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createPortal } from 'react-dom';
import { Button } from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { useChangeBacklogItemStatus } from '../../../hooks/useBacklog';
import { BacklogItemStatus } from '../../../types/deliverable.types';
import type { BacklogItem } from '../../../types/deliverable.types';
const rejectSchema = z.object({
rejectionReason: z.string().min(1, 'Motivo da rejeição é obrigatório'),
});
type RejectFormData = z.infer<typeof rejectSchema>;
interface BacklogRejectModalProps {
isOpen: boolean;
onClose: () => void;
serviceOrderId: string;
item?: BacklogItem;
}
export function BacklogRejectModal({
isOpen,
onClose,
serviceOrderId,
item,
}: BacklogRejectModalProps) {
const { showToast } = useToast();
const changeStatus = useChangeBacklogItemStatus();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<RejectFormData>({
resolver: zodResolver(rejectSchema),
});
useEffect(() => {
if (isOpen) {
reset({ rejectionReason: '' });
}
}, [isOpen, reset]);
const handleClose = useCallback(() => {
if (isSubmitting) return;
onClose();
}, [onClose, isSubmitting]);
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]);
async function onSubmit(data: RejectFormData) {
if (!item) return;
try {
await changeStatus.mutateAsync({
osId: serviceOrderId,
itemId: item.id,
data: {
status: BacklogItemStatus.REJEITADO,
rejectionReason: data.rejectionReason,
},
});
showToast('Item rejeitado com sucesso', 'success');
handleClose();
} catch {
showToast('Erro ao rejeitar item', 'error');
}
}
if (!isOpen || !item) 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-2 text-lg font-semibold text-primary">Rejeitar Item</h2>
<p className="mb-4 text-small text-text-muted">
Rejeitando: <span className="font-medium text-primary">{item.title}</span>
</p>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<div>
<label className="mb-1 block text-small font-medium text-primary">
Motivo da Rejeição
</label>
<textarea
placeholder="Informe o motivo da rejeição..."
rows={4}
className={`w-full rounded-lg border px-3 py-2 text-body text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none ${
errors.rejectionReason ? 'border-danger' : 'border-border'
} bg-card`}
{...register('rejectionReason')}
/>
{errors.rejectionReason && (
<p className="mt-1 text-small text-danger">{errors.rejectionReason.message}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button variant="danger" type="submit" loading={isSubmitting}>
{isSubmitting ? 'Rejeitando...' : 'Rejeitar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,62 @@
import { DELIVERABLE_TYPE_LABELS, DeliverableType } from '../../../constants/deliverable-type';
import type {
DeliverableValuePreviewResult,
DeliverableValuePreviewStatus,
} from '../../../hooks/useDeliverableValuePreview';
interface Props {
preview: DeliverableValuePreviewResult;
type?: DeliverableType | null;
}
const STATUS_MESSAGE: Record<
Exclude<DeliverableValuePreviewStatus, 'OK' | 'MISSING_NUM_WEEKS'>,
string
> = {
MISSING_ITEM_TIMEBOX: 'Configure o time-box do tipo selecionado no Item de Contrato',
MISSING_MANUTENCAO_TIMEBOX: 'Informe o time-box de manutenção',
MISSING_UST: 'Item de contrato sem valor da UST',
MISSING_TYPE: 'Selecione o tipo de entrega',
MISSING_CONTRACT_ITEM: 'Selecione uma Ordem de Serviço',
};
export function DeliverableValuePreviewBlock({ preview, type }: Props) {
if (preview.status === 'OK') {
return (
<div className="rounded-md border border-isis-blue/30 bg-isis-blue/5 p-3">
<p className="text-small font-medium text-primary">
Valor calculado: R${' '}
{preview.value.toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{preview.formula && (
<p className="mt-1 text-xs text-text-muted">
{preview.formula}
{type === DeliverableType.MANUTENCAO ? ' · time-box do Entregável' : ''}
</p>
)}
</div>
);
}
if (preview.status === 'MISSING_NUM_WEEKS') {
return (
<div className="rounded-md border border-border bg-surface-muted/50 p-3">
<p className="text-small text-text-muted">Informe o de semanas para calcular o valor</p>
</div>
);
}
const message = STATUS_MESSAGE[preview.status];
return (
<div className="rounded-md border border-danger/30 bg-danger/10 p-3 text-danger">
<p className="text-small font-medium">{message}</p>
{type && type === DeliverableType.MANUTENCAO && preview.status === 'MISSING_ITEM_TIMEBOX' && (
<p className="mt-1 text-xs">Tipo: {DELIVERABLE_TYPE_LABELS[type]}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,435 @@
import { useState, useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createPortal } from 'react-dom';
import { Layers, Plus, Pencil, Trash2, Copy, Info } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { DataTable } from '../../../components/ui/DataTable';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { Input } from '../../../components/ui/Input';
import { Select } from '../../../components/ui/Select';
import { useToast } from '../../../components/ui/Toast';
import {
useDeliverableAllocations,
useCreateDeliverableAllocation,
useUpdateDeliverableAllocation,
useDeleteDeliverableAllocation,
useApplyTemplate,
} from '../../../hooks/useDeliverableAllocations';
import { useClientActiveProfiles } from '../../../hooks/useClientProfiles';
import type { OSAllocationListItem } from '../../../types/os-allocation.types';
import { ContractItemType } from '../../../types/contract-item.types';
const allocationSchema = z.object({
profileId: z.string().min(1, 'Perfil é obrigatório'),
quantity: z.number({ message: 'Quantidade é obrigatória' }).int().min(1, 'Quantidade mínima é 1'),
allocationPercentage: z
.number({ message: '% Alocação é obrigatório' })
.min(0, 'Valor mínimo é 0')
.max(100, 'Valor máximo é 100'),
});
type AllocationFormData = z.infer<typeof allocationSchema>;
interface AllocationModalProps {
isOpen: boolean;
onClose: () => void;
serviceOrderId: string;
clientId: string;
allocation?: OSAllocationListItem;
}
function AllocationModal({
isOpen,
onClose,
serviceOrderId,
clientId,
allocation,
}: AllocationModalProps) {
const isEditing = !!allocation;
const { showToast } = useToast();
const createAllocation = useCreateDeliverableAllocation(serviceOrderId);
const updateAllocation = useUpdateDeliverableAllocation(serviceOrderId);
const { data: profiles = [] } = useClientActiveProfiles(clientId);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<AllocationFormData>({
resolver: zodResolver(allocationSchema),
});
useEffect(() => {
if (isOpen) {
if (allocation) {
reset({
profileId: allocation.profile.id,
quantity: allocation.quantity,
allocationPercentage: allocation.allocationPercentage,
});
} else {
reset({
profileId: '',
quantity: 1,
allocationPercentage: 100,
});
}
}
}, [isOpen, allocation, reset]);
const handleClose = useCallback(() => {
if (isSubmitting) return;
onClose();
}, [onClose, isSubmitting]);
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]);
async function onSubmit(data: AllocationFormData) {
try {
if (isEditing) {
await updateAllocation.mutateAsync({
id: allocation.id,
data: {
profileId: data.profileId,
quantity: data.quantity,
allocationPercentage: data.allocationPercentage,
},
});
showToast('Alocação atualizada com sucesso', 'success');
} else {
await createAllocation.mutateAsync({
profileId: data.profileId,
quantity: data.quantity,
allocationPercentage: data.allocationPercentage,
});
showToast('Alocação adicionada com sucesso', 'success');
}
handleClose();
} catch {
showToast(isEditing ? 'Erro ao atualizar alocação' : 'Erro ao adicionar alocação', 'error');
}
}
const profileOptions = profiles.map((p) => ({
value: p.id,
label: p.name,
}));
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-4 text-lg font-semibold text-primary">
{isEditing ? 'Editar Alocação' : 'Adicionar Alocação'}
</h2>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<Select
label="Perfil"
placeholder="Selecione um perfil"
options={profileOptions}
error={errors.profileId?.message}
disabled={isEditing}
{...register('profileId')}
/>
<Input
label="Quantidade"
type="number"
min={1}
step={1}
error={errors.quantity?.message}
{...register('quantity', { valueAsNumber: true })}
/>
<Input
label="% Alocação"
type="number"
min={0}
max={100}
step={1}
error={errors.allocationPercentage?.message}
{...register('allocationPercentage', { valueAsNumber: true })}
/>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}
interface EntregavelAllocationTabProps {
serviceOrderId: string;
clientId: string;
readOnly?: boolean;
itemType?: ContractItemType | null;
}
function AllocationBanners({ itemType }: { itemType?: ContractItemType | null }) {
return (
<div className="mb-4 space-y-2">
<div className="flex items-start gap-2 rounded-md border border-isis-blue/30 bg-isis-blue/5 p-3 text-small text-text-secondary">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-isis-blue" />
<p>
Alocações são usadas para conformidade com o TR e{' '}
<strong>não influenciam o valor do Entregável</strong>.
</p>
</div>
{itemType === ContractItemType.SAAS_LICENSE && (
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 p-3 text-small text-warning">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0" />
<p>
Item Licença SaaS alocação registrada para conformidade com o TR; faturamento ocorre
uma vez ao ano via OS Mãe.
</p>
</div>
)}
</div>
);
}
export function EntregavelAllocationTab({
serviceOrderId,
clientId,
readOnly = false,
itemType,
}: EntregavelAllocationTabProps) {
const { data: allocations = [], isLoading } = useDeliverableAllocations(serviceOrderId);
const deleteAllocation = useDeleteDeliverableAllocation(serviceOrderId);
const applyTemplateMutation = useApplyTemplate(serviceOrderId);
const { showToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAllocation, setEditingAllocation] = useState<OSAllocationListItem | undefined>();
const [deletingAllocation, setDeletingAllocation] = useState<OSAllocationListItem | undefined>();
const [showApplyTemplateConfirm, setShowApplyTemplateConfirm] = useState(false);
function handleEdit(allocation: OSAllocationListItem) {
setEditingAllocation(allocation);
setIsModalOpen(true);
}
function handleAdd() {
setEditingAllocation(undefined);
setIsModalOpen(true);
}
function handleCloseModal() {
setIsModalOpen(false);
setEditingAllocation(undefined);
}
function handleConfirmDelete() {
if (!deletingAllocation) return;
deleteAllocation.mutate(deletingAllocation.id, {
onSuccess: () => {
showToast('Alocação removida com sucesso', 'success');
setDeletingAllocation(undefined);
},
onError: () => {
showToast('Erro ao remover alocação', 'error');
},
});
}
function handleConfirmApplyTemplate() {
applyTemplateMutation.mutate('auto', {
onSuccess: () => {
showToast('Template aplicado com sucesso', 'success');
setShowApplyTemplateConfirm(false);
},
onError: () => {
showToast('Erro ao aplicar template. Verifique se existe um template compatível.', 'error');
setShowApplyTemplateConfirm(false);
},
});
}
const columns = [
{
key: 'profile',
header: 'Perfil',
render: (item: OSAllocationListItem) => (
<span className="font-medium text-primary">{item.profile.name}</span>
),
},
{
key: 'quantity',
header: 'Qtd',
render: (item: OSAllocationListItem) => item.quantity,
},
{
key: 'allocationPercentage',
header: '% Alocação',
render: (item: OSAllocationListItem) => `${Number(item.allocationPercentage)}%`,
},
...(!readOnly
? [
{
key: 'actions',
header: '',
render: (item: OSAllocationListItem) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeletingAllocation(item)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
},
]
: []),
];
if (!isLoading && allocations.length === 0) {
return (
<>
<AllocationBanners itemType={itemType} />
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card py-16 text-text-secondary">
<Layers className="h-10 w-10" />
<p className="text-lg font-medium">Nenhuma alocação registrada</p>
{!readOnly && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
icon={<Copy className="h-4 w-4" />}
onClick={() => setShowApplyTemplateConfirm(true)}
>
Aplicar Template
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Alocação
</Button>
</div>
)}
</div>
<AllocationModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
clientId={clientId}
/>
<ConfirmDialog
open={showApplyTemplateConfirm}
title="Aplicar Template"
message="Isso substituirá todas as alocações atuais. Deseja continuar?"
confirmLabel="Aplicar"
variant="default"
loading={applyTemplateMutation.isPending}
onConfirm={handleConfirmApplyTemplate}
onCancel={() => setShowApplyTemplateConfirm(false)}
/>
</>
);
}
return (
<>
<AllocationBanners itemType={itemType} />
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-primary">Alocações</h3>
{!readOnly && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
icon={<Copy className="h-4 w-4" />}
onClick={() => setShowApplyTemplateConfirm(true)}
>
Aplicar Template
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Alocação
</Button>
</div>
)}
</div>
<DataTable
columns={columns}
data={allocations}
isLoading={isLoading}
emptyMessage="Nenhuma alocação registrada"
rowKey="id"
/>
<AllocationModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
clientId={clientId}
allocation={editingAllocation}
/>
<ConfirmDialog
open={!!deletingAllocation}
title="Remover alocação"
message={`Deseja remover a alocação do perfil ${deletingAllocation?.profile.name}?`}
confirmLabel="Remover"
variant="danger"
loading={deleteAllocation.isPending}
onConfirm={handleConfirmDelete}
onCancel={() => setDeletingAllocation(undefined)}
/>
<ConfirmDialog
open={showApplyTemplateConfirm}
title="Aplicar Template"
message="Isso substituirá todas as alocações atuais. Deseja continuar?"
confirmLabel="Aplicar"
variant="default"
loading={applyTemplateMutation.isPending}
onConfirm={handleConfirmApplyTemplate}
onCancel={() => setShowApplyTemplateConfirm(false)}
/>
</>
);
}

View File

@@ -0,0 +1,355 @@
import { useState } from 'react';
import {
LayoutList,
Plus,
Pencil,
Trash2,
ChevronUp,
ChevronDown,
Check,
X,
RotateCcw,
} from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { Badge } from '../../../components/ui/Badge';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { useToast } from '../../../components/ui/Toast';
import {
useBacklogItems,
useDeleteBacklogItem,
useReorderBacklogItems,
useChangeBacklogItemStatus,
} from '../../../hooks/useBacklog';
import { BacklogItemStatus } from '../../../types/deliverable.types';
import type { BacklogItem } from '../../../types/deliverable.types';
import { BacklogItemModal } from './BacklogItemModal';
import { BacklogRejectModal } from './BacklogRejectModal';
const statusBadgeConfig: Record<
string,
{ variant: 'neutral' | 'success' | 'danger'; label: string }
> = {
[BacklogItemStatus.PENDENTE]: { variant: 'neutral', label: 'Pendente' },
[BacklogItemStatus.ACEITO]: { variant: 'success', label: 'Aceito' },
[BacklogItemStatus.REJEITADO]: { variant: 'danger', label: 'Rejeitado' },
};
interface EntregavelBacklogTabProps {
serviceOrderId: string;
readOnly?: boolean;
}
export function EntregavelBacklogTab({
serviceOrderId,
readOnly = false,
}: EntregavelBacklogTabProps) {
const { data: items = [], isLoading } = useBacklogItems(serviceOrderId);
const deleteItem = useDeleteBacklogItem();
const reorderItems = useReorderBacklogItems();
const changeStatus = useChangeBacklogItemStatus();
const { showToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<BacklogItem | undefined>();
const [deletingItem, setDeletingItem] = useState<BacklogItem | undefined>();
const [rejectingItem, setRejectingItem] = useState<BacklogItem | undefined>();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const acceptedCount = items.filter((i) => i.status === BacklogItemStatus.ACEITO).length;
function handleAdd() {
setEditingItem(undefined);
setIsModalOpen(true);
}
function handleEdit(item: BacklogItem) {
setEditingItem(item);
setIsModalOpen(true);
}
function handleCloseModal() {
setIsModalOpen(false);
setEditingItem(undefined);
}
function handleConfirmDelete() {
if (!deletingItem) return;
deleteItem.mutate(
{ osId: serviceOrderId, itemId: deletingItem.id },
{
onSuccess: () => {
showToast('Item removido com sucesso', 'success');
setDeletingItem(undefined);
},
onError: () => {
showToast('Erro ao remover item', 'error');
},
},
);
}
function handleMoveUp(index: number) {
if (index === 0) return;
const reordered = [...items];
const temp = reordered[index - 1];
reordered[index - 1] = reordered[index];
reordered[index] = temp;
reorderItems.mutate(
{
osId: serviceOrderId,
data: { items: reordered.map((item, i) => ({ id: item.id, sortOrder: i + 1 })) },
},
{
onError: () => showToast('Erro ao reordenar itens', 'error'),
},
);
}
function handleMoveDown(index: number) {
if (index === items.length - 1) return;
const reordered = [...items];
const temp = reordered[index + 1];
reordered[index + 1] = reordered[index];
reordered[index] = temp;
reorderItems.mutate(
{
osId: serviceOrderId,
data: { items: reordered.map((item, i) => ({ id: item.id, sortOrder: i + 1 })) },
},
{
onError: () => showToast('Erro ao reordenar itens', 'error'),
},
);
}
function handleAccept(item: BacklogItem) {
changeStatus.mutate(
{
osId: serviceOrderId,
itemId: item.id,
data: { status: BacklogItemStatus.ACEITO },
},
{
onSuccess: () => showToast('Item aceito com sucesso', 'success'),
onError: () => showToast('Erro ao aceitar item', 'error'),
},
);
}
function handleReopen(item: BacklogItem) {
changeStatus.mutate(
{
osId: serviceOrderId,
itemId: item.id,
data: { status: BacklogItemStatus.PENDENTE },
},
{
onSuccess: () => showToast('Item reaberto com sucesso', 'success'),
onError: () => showToast('Erro ao reabrir item', 'error'),
},
);
}
function toggleExpand(itemId: string) {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
);
}
if (items.length === 0) {
return (
<>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card py-16 text-text-secondary">
<LayoutList className="h-10 w-10" />
<p className="text-lg font-medium">Nenhum item de backlog cadastrado</p>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Item
</Button>
)}
</div>
<BacklogItemModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
/>
</>
);
}
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-primary">Backlog</h3>
<span className="text-small text-text-muted">
{acceptedCount} de {items.length} aceitos
</span>
</div>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Item
</Button>
)}
</div>
<div className="divide-y divide-border rounded-lg border border-border bg-card">
{items.map((item, index) => {
const config = statusBadgeConfig[item.status];
const isExpanded = expandedItems.has(item.id);
return (
<div key={item.id} className="px-4 py-3">
<div className="flex items-start gap-3">
{/* Número sequencial */}
<span className="mt-0.5 flex-shrink-0 text-small font-medium text-text-muted">
#{index + 1}
</span>
{/* Conteúdo principal */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-primary">{item.title}</span>
<Badge variant={config.variant} size="sm">
{config.label}
</Badge>
</div>
{item.acceptanceCriteria && (
<button
type="button"
className="mt-1 text-left text-small text-text-muted hover:text-text-secondary"
onClick={() => toggleExpand(item.id)}
>
{isExpanded
? item.acceptanceCriteria
: `${item.acceptanceCriteria.slice(0, 100)}${item.acceptanceCriteria.length > 100 ? '...' : ''}`}
</button>
)}
{item.status === BacklogItemStatus.REJEITADO && item.rejectionReason && (
<p className="mt-1 text-small text-danger">Motivo: {item.rejectionReason}</p>
)}
</div>
{/* Ações */}
{!readOnly && (
<div className="flex flex-shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleMoveDown(index)}
disabled={index === items.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
{item.status === BacklogItemStatus.PENDENTE && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handleAccept(item)}
title="Aceitar"
>
<Check className="h-4 w-4 text-success" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setRejectingItem(item)}
title="Rejeitar"
>
<X className="h-4 w-4 text-danger" />
</Button>
</>
)}
{(item.status === BacklogItemStatus.REJEITADO ||
item.status === BacklogItemStatus.ACEITO) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleReopen(item)}
title="Reabrir"
>
<RotateCcw className="h-4 w-4 text-info" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeletingItem(item)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
<BacklogItemModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
item={editingItem}
/>
<BacklogRejectModal
isOpen={!!rejectingItem}
onClose={() => setRejectingItem(undefined)}
serviceOrderId={serviceOrderId}
item={rejectingItem}
/>
<ConfirmDialog
open={!!deletingItem}
title="Remover item de backlog"
message={`Deseja remover o item "${deletingItem?.title}" do backlog?`}
confirmLabel="Remover"
variant="danger"
loading={deleteItem.isPending}
onConfirm={handleConfirmDelete}
onCancel={() => setDeletingItem(undefined)}
/>
</>
);
}

View File

@@ -0,0 +1,244 @@
import { useState } from 'react';
import {
MessageSquare,
Plus,
Pencil,
Eye,
AlertTriangle,
OctagonX,
Scale,
Star,
Clock,
} from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { Badge } from '../../../components/ui/Badge';
import { Select } from '../../../components/ui/Select';
import { useToast } from '../../../components/ui/Toast';
import { useNotes, useUpdateNote } from '../../../hooks/useNotes';
import { NoteType } from '../../../types/deliverable.types';
import type { NoteItem } from '../../../types/deliverable.types';
import { NoteModal } from './NoteModal';
interface EntregavelNotesTabProps {
serviceOrderId: string;
readOnly?: boolean;
}
const NOTE_TYPE_OPTIONS = [
{ value: '', label: 'Todas' },
{ value: NoteType.OBSERVACAO, label: 'Observação' },
{ value: NoteType.RISCO, label: 'Risco' },
{ value: NoteType.IMPEDIMENTO, label: 'Impedimento' },
{ value: NoteType.DECISAO, label: 'Decisão' },
];
const NOTE_TYPE_CONFIG: Record<
NoteType,
{ icon: typeof Eye; label: string; variant: 'info' | 'warning' | 'danger' | 'purple' }
> = {
[NoteType.OBSERVACAO]: { icon: Eye, label: 'Observação', variant: 'info' },
[NoteType.RISCO]: { icon: AlertTriangle, label: 'Risco', variant: 'warning' },
[NoteType.IMPEDIMENTO]: { icon: OctagonX, label: 'Impedimento', variant: 'danger' },
[NoteType.DECISAO]: { icon: Scale, label: 'Decisão', variant: 'purple' },
};
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function EntregavelNotesTab({ serviceOrderId, readOnly = false }: EntregavelNotesTabProps) {
const [typeFilter, setTypeFilter] = useState<NoteType | undefined>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingNote, setEditingNote] = useState<NoteItem | undefined>();
const { data: notes = [], isLoading } = useNotes(serviceOrderId, {
type: typeFilter,
});
const updateNote = useUpdateNote();
const { showToast } = useToast();
function handleAdd() {
setEditingNote(undefined);
setIsModalOpen(true);
}
function handleEdit(note: NoteItem) {
setEditingNote(note);
setIsModalOpen(true);
}
function handleCloseModal() {
setIsModalOpen(false);
setEditingNote(undefined);
}
function handleToggleRelevance(note: NoteItem) {
updateNote.mutate(
{
osId: serviceOrderId,
noteId: note.id,
data: { isRelevant: !note.isRelevant },
},
{
onSuccess: () => {
showToast(note.isRelevant ? 'Relevância removida' : 'Marcada como relevante', 'success');
},
onError: () => {
showToast('Erro ao atualizar relevância', 'error');
},
},
);
}
function handleTypeChange(value: string) {
setTypeFilter(value ? (value as NoteType) : undefined);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
);
}
if (notes.length === 0 && !typeFilter) {
return (
<>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card py-16 text-text-secondary">
<MessageSquare className="h-10 w-10" />
<p className="text-lg font-medium">Nenhuma anotação registrada</p>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Anotação
</Button>
)}
</div>
<NoteModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
/>
</>
);
}
return (
<>
{/* Header com filtro e botão */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Select
options={NOTE_TYPE_OPTIONS}
value={typeFilter ?? ''}
onChange={(e) => handleTypeChange(e.target.value)}
className="w-48"
/>
</div>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Anotação
</Button>
)}
</div>
{/* Lista de anotações */}
{notes.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card py-16 text-text-secondary">
<MessageSquare className="h-10 w-10" />
<p className="text-body font-medium">Nenhuma anotação deste tipo</p>
</div>
) : (
<div className="divide-y divide-border rounded-lg border border-border bg-card">
{notes.map((note) => {
const config = NOTE_TYPE_CONFIG[note.type];
const Icon = config.icon;
return (
<div key={note.id} className="px-4 py-3">
<div className="flex items-start gap-3">
{/* Ícone do tipo */}
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-surface-subtle text-muted">
<Icon className="h-4 w-4" />
</div>
{/* Conteúdo */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-primary">{note.title}</span>
<Badge variant={config.variant} size="sm">
{config.label}
</Badge>
</div>
{note.description && (
<p className="mt-0.5 line-clamp-2 text-small text-text-secondary">
{note.description}
</p>
)}
<div className="mt-1 flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDateTime(note.createdAt)}
</span>
{note.createdBy && <span>por {note.createdBy}</span>}
</div>
</div>
{/* Ações */}
{!readOnly && (
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
onClick={() => handleToggleRelevance(note)}
className="rounded p-1.5 hover:bg-bg"
title={note.isRelevant ? 'Remover relevância' : 'Marcar como relevante'}
>
<Star
className={`h-4 w-4 ${note.isRelevant ? 'fill-warning text-warning' : 'text-text-muted'}`}
/>
</button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(note)}
title="Editar"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
<NoteModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
note={editingNote}
/>
</>
);
}

View File

@@ -0,0 +1,150 @@
import { Users, ClipboardList, MessageSquare } from 'lucide-react';
import { Link } from 'react-router-dom';
import { StatusBadge } from '../../../components/shared/StatusBadge';
import { StatusHistoryList } from '../../../components/shared/StatusHistoryList';
import { DELIVERABLE_TYPE_LABELS } from '../../../constants/deliverable-type';
import type { DeliverableDetail } from '../../../types/deliverable.types';
interface EntregavelSummaryTabProps {
serviceOrder: DeliverableDetail;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
const indicatorConfig = [
{ key: 'assignments', label: 'Profissionais', icon: Users },
{ key: 'backlogItems', label: 'Itens de Backlog', icon: ClipboardList },
{ key: 'notes', label: 'Anotações', icon: MessageSquare },
] as const;
export function EntregavelSummaryTab({ serviceOrder }: EntregavelSummaryTabProps) {
return (
<div className="space-y-6">
{/* Dados Gerais */}
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Dados Gerais</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Código</span>
<span className="text-body text-primary">{serviceOrder.code}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Título</span>
<span className="text-body text-primary">{serviceOrder.title}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Status</span>
<div>
<StatusBadge status={serviceOrder.status} />
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Tipo</span>
<span className="text-body text-primary">
{DELIVERABLE_TYPE_LABELS[serviceOrder.type] ?? serviceOrder.type}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Início</span>
<span className="text-body text-primary">{formatDate(serviceOrder.startDate)}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Previsão de Término</span>
<span className="text-body text-primary">
{formatDate(serviceOrder.expectedEndDate)}
</span>
</div>
</div>
</section>
{/* Vínculos */}
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Vínculos</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">OS Mãe</span>
{serviceOrder.workOrder ? (
<Link
to={`/ordens-servico/${serviceOrder.workOrder.id}`}
className="text-body text-isis-blue hover:underline"
>
{serviceOrder.workOrder.code} {serviceOrder.workOrder.name}
</Link>
) : (
<span className="text-body text-text-muted"></span>
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Cliente</span>
<span className="text-body text-primary">{serviceOrder.client.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Contrato</span>
<span className="text-body text-primary">{serviceOrder.contract.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Projeto</span>
<span className="text-body text-primary">{serviceOrder.project.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Item de Contrato</span>
<span className="text-body text-primary">
{serviceOrder.contractItem
? `${serviceOrder.contractItem.code}${serviceOrder.contractItem.name}`
: 'Não informado'}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-small text-text-muted">Sprint</span>
<span className="text-body text-primary">
{serviceOrder.sprint?.name ?? 'Não informada'}
</span>
</div>
</div>
</section>
{/* Descrição */}
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Descrição</h2>
{serviceOrder.description ? (
<p className="whitespace-pre-wrap text-body text-text-secondary">
{serviceOrder.description}
</p>
) : (
<p className="text-body text-text-muted">Sem descrição</p>
)}
</section>
{/* Indicadores */}
<section>
<h2 className="mb-4 text-base font-semibold text-primary">Indicadores</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{indicatorConfig.map(({ key, label, icon: Icon }) => (
<div
key={key}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-isis-blue/10">
<Icon className="h-5 w-5 text-isis-blue" />
</div>
<div>
<p className="text-2xl font-semibold text-primary">{serviceOrder._count[key]}</p>
<p className="text-small text-text-muted">{label}</p>
</div>
</div>
))}
</div>
</section>
{/* Histórico de Status */}
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Histórico de Status</h2>
<StatusHistoryList serviceOrderId={serviceOrder.id} />
</section>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Users, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { DataTable } from '../../../components/ui/DataTable';
import { ConfirmDialog } from '../../../components/ui/ConfirmDialog';
import { useToast } from '../../../components/ui/Toast';
import { useAssignments, useDeleteAssignment } from '../../../hooks/useAssignments';
import { AssignmentModal } from './AssignmentModal';
import type { AssignmentListItem } from '../../../types/deliverable.types';
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
function formatPeriod(startDate: string | null, endDate: string | null): string {
if (!startDate && !endDate) return '—';
return `${formatDate(startDate)}${formatDate(endDate)}`;
}
interface EntregavelTeamTabProps {
serviceOrderId: string;
readOnly?: boolean;
}
export function EntregavelTeamTab({ serviceOrderId, readOnly = false }: EntregavelTeamTabProps) {
const { data: assignments = [], isLoading } = useAssignments(serviceOrderId);
const deleteAssignment = useDeleteAssignment();
const { showToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAssignment, setEditingAssignment] = useState<AssignmentListItem | undefined>();
const [deletingAssignment, setDeletingAssignment] = useState<AssignmentListItem | undefined>();
function handleEdit(assignment: AssignmentListItem) {
setEditingAssignment(assignment);
setIsModalOpen(true);
}
function handleAdd() {
setEditingAssignment(undefined);
setIsModalOpen(true);
}
function handleCloseModal() {
setIsModalOpen(false);
setEditingAssignment(undefined);
}
function handleConfirmDelete() {
if (!deletingAssignment) return;
deleteAssignment.mutate(
{ osId: serviceOrderId, assignmentId: deletingAssignment.id },
{
onSuccess: () => {
showToast('Profissional removido com sucesso', 'success');
setDeletingAssignment(undefined);
},
onError: () => {
showToast('Erro ao remover profissional', 'error');
},
},
);
}
const columns = [
{
key: 'professional',
header: 'Profissional',
render: (item: AssignmentListItem) => (
<div>
<span className="font-medium text-primary">{item.professional.name}</span>
{item.professional.role && (
<span className="ml-2 text-small text-text-muted">{item.professional.role}</span>
)}
</div>
),
},
{
key: 'role',
header: 'Papel no entregável',
render: (item: AssignmentListItem) => item.role ?? '—',
},
{
key: 'period',
header: 'Período',
render: (item: AssignmentListItem) => formatPeriod(item.startDate, item.endDate),
},
{
key: 'observation',
header: 'Observação',
render: (item: AssignmentListItem) => (
<span className="block max-w-xs truncate" title={item.observation ?? undefined}>
{item.observation ?? '—'}
</span>
),
},
...(!readOnly
? [
{
key: 'actions',
header: '',
render: (item: AssignmentListItem) => (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeletingAssignment(item)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
},
]
: []),
];
if (!isLoading && assignments.length === 0) {
return (
<>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card py-16 text-text-secondary">
<Users className="h-10 w-10" />
<p className="text-lg font-medium">Nenhum profissional alocado</p>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Profissional
</Button>
)}
</div>
<AssignmentModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
/>
</>
);
}
return (
<>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-primary">Equipe</h3>
{!readOnly && (
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleAdd}
>
Adicionar Profissional
</Button>
)}
</div>
<DataTable
columns={columns}
data={assignments}
isLoading={isLoading}
emptyMessage="Nenhum profissional alocado"
rowKey="id"
/>
<AssignmentModal
isOpen={isModalOpen}
onClose={handleCloseModal}
serviceOrderId={serviceOrderId}
assignment={editingAssignment}
/>
<ConfirmDialog
open={!!deletingAssignment}
title="Remover profissional"
message={`Deseja remover ${deletingAssignment?.professional.name} da equipe deste entregável?`}
confirmLabel="Remover"
variant="danger"
loading={deleteAssignment.isPending}
onConfirm={handleConfirmDelete}
onCancel={() => setDeletingAssignment(undefined)}
/>
</>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import {
CirclePlus,
Pencil,
RefreshCw,
Users,
ClipboardList,
MessageSquare,
Clock,
ChevronDown,
ChevronUp,
UserCheck,
} from 'lucide-react';
import { Select } from '../../../components/ui/Select';
import { Badge } from '../../../components/ui/Badge';
import { Pagination } from '../../../components/ui/Pagination';
import { useTimeline } from '../../../hooks/useTimeline';
import { TimelineEventType } from '../../../types/deliverable.types';
import type { TimelineEvent } from '../../../types/deliverable.types';
interface EntregavelTimelineTabProps {
serviceOrderId: string;
}
const EVENT_TYPE_OPTIONS = [
{ value: '', label: 'Todos os tipos' },
{ value: TimelineEventType.CRIACAO, label: 'Criação' },
{ value: TimelineEventType.EDICAO, label: 'Edição' },
{ value: TimelineEventType.STATUS_CHANGE, label: 'Status' },
{ value: TimelineEventType.ASSIGNMENT, label: 'Equipe' },
{ value: TimelineEventType.BACKLOG, label: 'Backlog' },
{ value: TimelineEventType.ANOTACAO, label: 'Anotação' },
{ value: TimelineEventType.ALOCACAO, label: 'Alocação' },
{ value: TimelineEventType.VALOR_RECALCULADO, label: 'Valor recalculado' },
];
const EVENT_TYPE_CONFIG: Record<
TimelineEventType,
{
icon: typeof CirclePlus;
label: string;
variant: 'primary' | 'info' | 'warning' | 'success' | 'purple' | 'neutral';
}
> = {
[TimelineEventType.CRIACAO]: { icon: CirclePlus, label: 'Criação', variant: 'success' },
[TimelineEventType.EDICAO]: { icon: Pencil, label: 'Edição', variant: 'info' },
[TimelineEventType.STATUS_CHANGE]: { icon: RefreshCw, label: 'Status', variant: 'warning' },
[TimelineEventType.ASSIGNMENT]: { icon: Users, label: 'Equipe', variant: 'purple' },
[TimelineEventType.BACKLOG]: { icon: ClipboardList, label: 'Backlog', variant: 'neutral' },
[TimelineEventType.ANOTACAO]: { icon: MessageSquare, label: 'Anotação', variant: 'primary' },
[TimelineEventType.ALOCACAO]: { icon: UserCheck, label: 'Alocação', variant: 'purple' },
[TimelineEventType.VALOR_RECALCULADO]: {
icon: RefreshCw,
label: 'Valor recalculado',
variant: 'info',
},
};
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function TimelineItem({ event }: { event: TimelineEvent }) {
const [expanded, setExpanded] = useState(false);
const config = EVENT_TYPE_CONFIG[event.type];
const Icon = config.icon;
const isManual = event.type === TimelineEventType.ANOTACAO;
const hasDetails = event.description && event.description.length > 80;
return (
<div
className={`flex gap-3 border-b border-border p-4 last:border-b-0 ${isManual ? 'bg-isis-blue/5' : ''}`}
>
<div
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${isManual ? 'bg-isis-blue/20 text-isis-blue' : 'bg-surface-subtle text-muted'}`}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-primary">{event.title}</span>
<Badge variant={config.variant} size="sm">
{config.label}
</Badge>
</div>
{!expanded && event.description && (
<p
className={`mt-0.5 text-small text-text-secondary ${hasDetails ? 'truncate' : ''}`}
>
{event.description}
</p>
)}
{expanded && event.description && (
<p className="mt-2 text-small text-text-secondary">{event.description}</p>
)}
</div>
{hasDetails && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex-shrink-0 rounded p-1 text-text-muted hover:bg-bg hover:text-text-secondary"
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDateTime(event.createdAt)}
</span>
{event.createdBy && <span>por {event.createdBy}</span>}
</div>
</div>
</div>
);
}
export function EntregavelTimelineTab({ serviceOrderId }: EntregavelTimelineTabProps) {
const [typeFilter, setTypeFilter] = useState<TimelineEventType | undefined>();
const [page, setPage] = useState(1);
const limit = 20;
const { data, isLoading } = useTimeline(serviceOrderId, {
type: typeFilter,
page,
limit,
});
const events = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
function handleTypeChange(value: string) {
setTypeFilter(value ? (value as TimelineEventType) : undefined);
setPage(1);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
);
}
return (
<div className="space-y-4">
{/* Filtro */}
<div className="flex items-center gap-3">
<Select
options={EVENT_TYPE_OPTIONS}
value={typeFilter ?? ''}
onChange={(e) => handleTypeChange(e.target.value)}
className="w-48"
/>
</div>
{/* Lista de eventos */}
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-text-secondary">
<Clock className="h-10 w-10" />
<p className="text-body font-medium">Nenhum evento encontrado</p>
<p className="text-small">
{typeFilter
? 'Nenhum evento deste tipo foi registrado'
: 'A timeline deste entregável ainda está vazia'}
</p>
</div>
) : (
<div className="rounded-lg border border-border bg-card">
{events.map((event) => (
<TimelineItem key={event.id} event={event} />
))}
</div>
)}
{/* Paginação */}
<Pagination
page={page}
totalPages={totalPages}
totalItems={total}
itemLabel="evento"
itemLabelPlural="eventos"
onPageChange={setPage}
/>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { DollarSign } from 'lucide-react';
import { ContractItemType } from '../../../types/contract-item.types';
import { DELIVERABLE_TYPE_LABELS, DeliverableType } from '../../../constants/deliverable-type';
import type { DeliverableDetail } from '../../../types/deliverable.types';
interface Props {
deliverable: DeliverableDetail;
}
function formatCurrency(value: number): string {
return value.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
});
}
function formatNumber(value: number): string {
return value.toLocaleString('pt-BR', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
function resolveTimebox(deliverable: DeliverableDetail): {
value: number | null;
source: 'CONTRACT_ITEM' | 'DELIVERABLE' | null;
} {
if (deliverable.type === DeliverableType.MANUTENCAO) {
return { value: deliverable.timeboxManutencao ?? null, source: 'DELIVERABLE' };
}
const ci = deliverable.contractItem;
if (!ci) return { value: null, source: null };
switch (deliverable.type) {
case DeliverableType.DESCOBERTA:
return { value: ci.timeboxDescoberta, source: 'CONTRACT_ITEM' };
case DeliverableType.DESIGN:
return { value: ci.timeboxDesign, source: 'CONTRACT_ITEM' };
case DeliverableType.ARQUITETURA:
return { value: ci.timeboxArquitetura, source: 'CONTRACT_ITEM' };
case DeliverableType.CONSTRUCAO:
return { value: ci.timeboxConstrucao, source: 'CONTRACT_ITEM' };
default:
return { value: null, source: null };
}
}
export function EntregavelValuationCard({ deliverable }: Props) {
const ci = deliverable.contractItem;
const isSaas = ci?.itemType === ContractItemType.SAAS_LICENSE;
const { value: timebox, source } = resolveTimebox(deliverable);
const totalValueNumber = deliverable.totalValue != null ? Number(deliverable.totalValue) : null;
if (deliverable.type === DeliverableType.LICENCA) {
if (totalValueNumber == null || ci?.ustValue == null) {
return (
<ValuationWarning
title="Item de contrato sem valor da UST"
description="Configure o valor da UST no Item de Contrato vinculado para calcular o valor da licença."
/>
);
}
return (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-isis-blue/10">
<DollarSign className="h-5 w-5 text-isis-blue" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted">
Valor Total
</p>
<p className="text-2xl font-bold text-isis-blue">
{formatCurrency(totalValueNumber)}
</p>
</div>
<p className="text-small text-text-secondary">
1 licença × R$ {formatNumber(ci.ustValue)}/unidade
</p>
</div>
</div>
</div>
);
}
if (isSaas) {
return (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/10">
<DollarSign className="h-5 w-5 text-warning" />
</div>
<div className="flex flex-1 flex-col gap-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted">
Valor Total
</p>
<p className="text-2xl font-bold text-warning">R$ 0,00</p>
<span className="mt-1 inline-flex w-fit rounded-md bg-warning/20 px-2 py-0.5 text-xs font-medium text-warning">
Licença SaaS faturamento via OS Mãe
</span>
</div>
</div>
</div>
);
}
if (deliverable.type === DeliverableType.MANUTENCAO && deliverable.timeboxManutencao == null) {
return (
<ValuationWarning
title="Time-box de manutenção não informado"
description="Informe o time-box no formulário de edição do Entregável para calcular o valor."
/>
);
}
if (deliverable.type !== DeliverableType.MANUTENCAO && timebox == null) {
return (
<ValuationWarning
title={`Item de contrato sem time-box configurado para o tipo ${DELIVERABLE_TYPE_LABELS[deliverable.type]}`}
description="Configure o time-box correspondente no Item de Contrato vinculado."
/>
);
}
if (totalValueNumber == null || timebox == null || ci?.ustValue == null) {
return (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-isis-blue/10">
<DollarSign className="h-5 w-5 text-isis-blue" />
</div>
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted">
Valor Total
</p>
<p className="text-body text-text-muted">Não valorado</p>
</div>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-isis-blue/10">
<DollarSign className="h-5 w-5 text-isis-blue" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted">
Valor Total
</p>
<p className="text-2xl font-bold text-isis-blue">{formatCurrency(totalValueNumber)}</p>
</div>
<p className="text-small text-text-secondary">
{formatNumber(timebox)}h × {deliverable.numWeeks} sem × R$ {formatNumber(ci.ustValue)}
/UST
</p>
<p className="text-xs text-text-muted">
Time-box{' '}
{source === 'DELIVERABLE'
? '(definido no Entregável)'
: '(definido no Item de Contrato)'}
</p>
</div>
</div>
</div>
);
}
function ValuationWarning({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/20">
<DollarSign className="h-5 w-5 text-warning" />
</div>
<div>
<p className="text-body font-medium text-warning">{title}</p>
<p className="mt-1 text-small text-text-secondary">{description}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useCallback } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createPortal } from 'react-dom';
import { Button, Input, Select } from '../../../components/ui';
import { useToast } from '../../../components/ui/Toast';
import { useCreateNote, useUpdateNote } from '../../../hooks/useNotes';
import { NoteType } from '../../../types/deliverable.types';
import type { NoteItem } from '../../../types/deliverable.types';
const NOTE_TYPE_OPTIONS = [
{ value: NoteType.OBSERVACAO, label: 'Observação' },
{ value: NoteType.RISCO, label: 'Risco' },
{ value: NoteType.IMPEDIMENTO, label: 'Impedimento' },
{ value: NoteType.DECISAO, label: 'Decisão' },
];
const noteSchema = z.object({
type: z.nativeEnum(NoteType, { message: 'Tipo é obrigatório' }),
title: z.string().min(1, 'Título é obrigatório'),
description: z.string().optional(),
isRelevant: z.boolean().optional(),
});
type NoteFormData = z.infer<typeof noteSchema>;
interface NoteModalProps {
isOpen: boolean;
onClose: () => void;
serviceOrderId: string;
note?: NoteItem;
}
export function NoteModal({ isOpen, onClose, serviceOrderId, note }: NoteModalProps) {
const isEditing = !!note;
const { showToast } = useToast();
const createNote = useCreateNote();
const updateNote = useUpdateNote();
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<NoteFormData>({
resolver: zodResolver(noteSchema),
});
useEffect(() => {
if (isOpen) {
if (note) {
reset({
type: note.type,
title: note.title,
description: note.description ?? '',
isRelevant: note.isRelevant,
});
} else {
reset({ type: undefined, title: '', description: '', isRelevant: false });
}
}
}, [isOpen, note, reset]);
const handleClose = useCallback(() => {
if (isSubmitting) return;
onClose();
}, [onClose, isSubmitting]);
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]);
async function onSubmit(data: NoteFormData) {
try {
if (isEditing) {
await updateNote.mutateAsync({
osId: serviceOrderId,
noteId: note.id,
data: {
type: data.type,
title: data.title,
description: data.description || undefined,
isRelevant: data.isRelevant,
},
});
showToast('Anotação atualizada com sucesso', 'success');
} else {
await createNote.mutateAsync({
osId: serviceOrderId,
data: {
type: data.type,
title: data.title,
description: data.description || undefined,
isRelevant: data.isRelevant,
},
});
showToast('Anotação criada com sucesso', 'success');
}
handleClose();
} catch {
showToast(isEditing ? 'Erro ao atualizar anotação' : 'Erro ao criar anotação', '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-4 text-lg font-semibold text-primary">
{isEditing ? 'Editar Anotação' : 'Nova Anotação'}
</h2>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-4">
<Controller
name="type"
control={control}
render={({ field }) => (
<Select
label="Tipo"
placeholder="Selecione o tipo"
options={NOTE_TYPE_OPTIONS}
value={field.value ?? ''}
onChange={field.onChange}
error={errors.type?.message}
/>
)}
/>
<Input
label="Título"
placeholder="Título da anotação"
error={errors.title?.message}
{...register('title')}
/>
<div>
<label className="mb-1 block text-small font-medium text-primary">Descrição</label>
<textarea
placeholder="Descrição da anotação..."
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"
{...register('description')}
/>
</div>
<label className="flex items-center gap-2 text-small text-primary">
<input
type="checkbox"
className="h-4 w-4 rounded border-border text-isis-blue focus:ring-isis-blue"
{...register('isRelevant')}
/>
Marcar como relevante
</label>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,15 @@
import { Construction } from 'lucide-react';
interface PlaceholderTabProps {
label: string;
}
export function PlaceholderTab({ label }: PlaceholderTabProps) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-text-secondary">
<Construction className="h-10 w-10" />
<p className="text-body font-medium">{label}</p>
<p className="text-small">Em desenvolvimento</p>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../../../../components/ui/Toast';
import { EntregavelAllocationTab } from '../EntregavelAllocationTab';
import { ContractItemType } from '../../../../types/contract-item.types';
vi.mock('../../../../hooks/useDeliverableAllocations', () => ({
useDeliverableAllocations: () => ({
data: [
{
id: 'alloc-1',
quantity: 2,
allocationPercentage: 100,
calculatedValue: 12000,
ustValue: 500,
weeklyHours: 12,
weeks: 1,
isActive: true,
createdAt: '2026-04-30T00:00:00.000Z',
updatedAt: '2026-04-30T00:00:00.000Z',
profile: { id: 'p-1', name: 'Desenvolvedor Pleno' },
},
],
isLoading: false,
}),
useCreateDeliverableAllocation: () => ({ mutateAsync: vi.fn() }),
useUpdateDeliverableAllocation: () => ({ mutateAsync: vi.fn() }),
useDeleteDeliverableAllocation: () => ({ mutate: vi.fn(), isPending: false }),
useApplyTemplate: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock('../../../../hooks/useClientProfiles', () => ({
useClientActiveProfiles: () => ({ data: [] }),
}));
function renderTab(props?: Partial<React.ComponentProps<typeof EntregavelAllocationTab>>) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ToastProvider>
<EntregavelAllocationTab serviceOrderId="d-1" clientId="c-1" {...props} />
</ToastProvider>
</QueryClientProvider>,
);
}
describe('EntregavelAllocationTab — NBC-020 (sem valoração visível)', () => {
it('renderiza colunas Perfil, Qtd e % Alocação', () => {
renderTab();
expect(screen.getByText('Perfil')).toBeInTheDocument();
expect(screen.getByText('Qtd')).toBeInTheDocument();
expect(screen.getByText('% Alocação')).toBeInTheDocument();
});
it('não renderiza coluna "Valor Calculado"', () => {
renderTab();
expect(screen.queryByText(/Valor Calculado/i)).toBeNull();
});
it('não renderiza coluna "h/semana"', () => {
renderTab();
expect(screen.queryByText(/h\/semana/i)).toBeNull();
});
it('não renderiza rodapé "Total estimado"', () => {
renderTab();
expect(screen.queryByText(/Total estimado/i)).toBeNull();
});
it('renderiza banner geral informativo', () => {
renderTab();
expect(screen.getByText(/não influenciam o valor do Entregável/i)).toBeInTheDocument();
});
it('renderiza banner SaaS específico quando itemType=SAAS_LICENSE', () => {
renderTab({ itemType: ContractItemType.SAAS_LICENSE });
expect(screen.getByText(/Item Licença SaaS/i)).toBeInTheDocument();
});
it('renderiza dados da alocação (perfil, qtd, %)', () => {
renderTab();
expect(screen.getByText('Desenvolvedor Pleno')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,114 @@
import { render as rtlRender, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import type { ReactElement } from 'react';
import { EntregavelSummaryTab } from '../EntregavelSummaryTab';
function render(ui: ReactElement) {
return rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
}
import { DeliverableStatus } from '../../../../types/deliverable.types';
import type { DeliverableDetail } from '../../../../types/deliverable.types';
import { DeliverableType } from '../../../../constants/deliverable-type';
vi.mock('../../../../hooks/useDeliverables', () => ({
useStatusHistory: () => ({ data: [], isLoading: false }),
}));
const baseDeliverable: DeliverableDetail = {
id: 'deliverable-id-1',
code: 'ENT-001',
title: 'Entregável de Teste',
description: 'Descrição detalhada do entregável',
status: DeliverableStatus.EM_EXECUCAO,
type: DeliverableType.CONSTRUCAO,
workOrderId: 'wo-1',
workOrder: { id: 'wo-1', code: 'OS-001', name: 'OS Teste', status: 'EMITIDA' },
client: { id: 'c1', name: 'Cliente Teste' },
contract: { id: 'ct1', name: 'Contrato Teste' },
project: { id: 'p1', name: 'Projeto Alpha' },
contractItem: null,
sprint: {
id: 's1',
name: 'Sprint 1',
startDate: '2026-01-15T00:00:00.000Z',
endDate: '2026-01-29T00:00:00.000Z',
},
startDate: '2026-01-15T00:00:00.000Z',
expectedEndDate: '2026-03-15T00:00:00.000Z',
numWeeks: 1,
timeboxManutencao: null,
ustValue: 150,
ustQuantity: 10,
totalValue: 1500,
createdAt: '2026-01-10T00:00:00.000Z',
updatedAt: '2026-02-20T10:00:00.000Z',
isActive: true,
createdBy: 'user-1',
updatedBy: 'user-1',
_count: { assignments: 3, backlogItems: 5, notes: 2, allocations: 0 },
statusHistory: [
{
previousStatus: DeliverableStatus.EMITIDA,
newStatus: DeliverableStatus.EM_EXECUCAO,
createdAt: '2026-02-01T00:00:00.000Z',
},
],
};
describe('EntregavelSummaryTab', () => {
it('exibe dados gerais: código, título, status, datas formatadas', () => {
render(<EntregavelSummaryTab serviceOrder={baseDeliverable} />);
expect(screen.getByText('ENT-001')).toBeInTheDocument();
expect(screen.getByText('Entregável de Teste')).toBeInTheDocument();
expect(screen.getByText('Dados Gerais')).toBeInTheDocument();
});
it('exibe vínculos: cliente, contrato, projeto, sprint', () => {
render(<EntregavelSummaryTab serviceOrder={baseDeliverable} />);
expect(screen.getByText('Cliente Teste')).toBeInTheDocument();
expect(screen.getByText('Contrato Teste')).toBeInTheDocument();
expect(screen.getByText('Projeto Alpha')).toBeInTheDocument();
expect(screen.getByText('Sprint 1')).toBeInTheDocument();
});
it('exibe "Não informada" quando sprint é null', () => {
const deliverableWithoutSprint = { ...baseDeliverable, sprint: null };
render(<EntregavelSummaryTab serviceOrder={deliverableWithoutSprint} />);
expect(screen.getByText('Não informada')).toBeInTheDocument();
});
it('exibe "Sem descrição" quando descrição é null', () => {
const deliverableWithoutDesc = { ...baseDeliverable, description: null };
render(<EntregavelSummaryTab serviceOrder={deliverableWithoutDesc} />);
expect(screen.getByText('Sem descrição')).toBeInTheDocument();
});
it('exibe descrição quando presente', () => {
render(<EntregavelSummaryTab serviceOrder={baseDeliverable} />);
expect(screen.getByText('Descrição detalhada do entregável')).toBeInTheDocument();
});
it('exibe indicadores com contagens corretas', () => {
render(<EntregavelSummaryTab serviceOrder={baseDeliverable} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Profissionais')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('Itens de Backlog')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('Anotações')).toBeInTheDocument();
});
it('renderiza seção de Histórico de Status', () => {
render(<EntregavelSummaryTab serviceOrder={baseDeliverable} />);
expect(screen.getByText('Histórico de Status')).toBeInTheDocument();
expect(screen.getByText('Nenhuma mudança de status registrada.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { DataTable, Pagination, Badge } from '../../components/ui';
import { StatusBadge } from '../../components/shared/StatusBadge';
import { useDeliverables } from '../../hooks/useDeliverables';
import { useFieldVisibility } from '../../hooks/useFieldVisibility';
import { DELIVERABLE_TYPE_LABELS } from '../../constants/deliverable-type';
import type { DeliverableListItem } from '../../types/deliverable.types';
const ITEMS_PER_PAGE = 20;
export function FiscalContratoPage() {
const breadcrumbs = useBreadcrumbs();
const isVisible = useFieldVisibility();
const [page, setPage] = useState(1);
const { data, isLoading } = useDeliverables({ page, limit: ITEMS_PER_PAGE });
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const columns = [
{
key: 'code',
header: 'Código',
render: (item: DeliverableListItem) => (
<Link to={`/entregaveis/${item.id}`} className="text-isis-blue hover:underline font-medium">
{item.code}
</Link>
),
},
{
key: 'title',
header: 'Título',
render: (item: DeliverableListItem) => (
<span className="text-text-primary">{item.title}</span>
),
},
{
key: 'status',
header: 'Status',
render: (item: DeliverableListItem) => <StatusBadge status={item.status} />,
},
{
key: 'type',
header: 'Tipo',
render: (item: DeliverableListItem) => (
<Badge variant="info">{DELIVERABLE_TYPE_LABELS[item.type]}</Badge>
),
},
...(isVisible('statusHistory')
? [
{
key: 'history',
header: 'Histórico',
render: () => (
<span className="text-text-secondary text-sm">Ver detalhes</span>
),
},
]
: []),
{
key: 'actions',
header: 'Ações',
render: (item: DeliverableListItem) => (
<Link to={`/entregaveis/${item.id}`} aria-label="Ver detalhes">
<Eye size={16} className="text-isis-blue" />
</Link>
),
},
];
return (
<PageContainer>
<PageHeader title="Fiscalização de Entregáveis" breadcrumbs={breadcrumbs} />
<DataTable<DeliverableListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum entregável encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="entregável"
itemLabelPlural="entregáveis"
onPageChange={setPage}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { FiscalContratoPage } from '../FiscalContratoPage';
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
import type { UserRole } from '../../../types/auth.types';
const mockDeliverables = {
data: [
{
id: 'del-2',
code: 'ENT-002',
title: 'Entregável Fiscalização',
status: 'EM_EXECUCAO',
type: 'DESENVOLVIMENTO',
workOrderId: 'wo-1',
workOrder: null,
client: { id: 'c1', name: 'Empresa X' },
contract: { id: 'ct1', name: 'Contrato 1' },
project: { id: 'p1', name: 'Projeto Beta' },
contractItem: { id: 'ci1', name: 'Item 1', code: 'CI-001', itemType: 'UST', ustValue: 5000, timeboxDescoberta: null, timeboxDesign: null, timeboxArquitetura: null, timeboxConstrucao: null, timeboxManutencao: null },
ustQuantity: 10,
totalValue: 50000,
numWeeks: 3,
startDate: '2026-01-01',
expectedEndDate: '2026-02-01',
isActive: true,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
],
total: 1,
page: 1,
limit: 20,
};
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverables: vi.fn(() => ({ data: mockDeliverables, isLoading: false })),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: vi.fn(() => []),
}));
vi.mock('../../../modules/auth', () => ({
useAuth: vi.fn(() => ({
user: { id: '2', name: 'Fiscal User', email: 'fiscal@x.com', role: 'FISCAL_CONTRATO' as UserRole },
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
})),
}));
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PageTitleProvider>
<FiscalContratoPage />
</PageTitleProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('FiscalContratoPage', () => {
it('renders the deliverables table with headers', () => {
renderPage();
expect(screen.getByText('Código')).toBeInTheDocument();
expect(screen.getByText('Título')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
});
it('renders deliverable data without financial fields', () => {
renderPage();
expect(screen.getByText('ENT-002')).toBeInTheDocument();
expect(screen.getByText('Entregável Fiscalização')).toBeInTheDocument();
expect(screen.queryByText('50000')).not.toBeInTheDocument();
expect(screen.queryByText('5000')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { DataTable, Pagination, Badge } from '../../components/ui';
import { useWorkOrders } from '../../hooks/useWorkOrders';
import { useFieldVisibility } from '../../hooks/useFieldVisibility';
import type { WorkOrderListItem } from '../../types/work-order.types';
const ITEMS_PER_PAGE = 20;
export function GestorContratoPage() {
const breadcrumbs = useBreadcrumbs();
const isVisible = useFieldVisibility();
const [page, setPage] = useState(1);
const { data, isLoading } = useWorkOrders({ page, limit: ITEMS_PER_PAGE });
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const columns = [
{
key: 'code',
header: 'Código',
render: (item: WorkOrderListItem) => (
<Link to={`/ordens-servico/${item.id}`} className="text-isis-blue hover:underline font-medium">
{item.code}
</Link>
),
},
{
key: 'name',
header: 'Nome',
render: (item: WorkOrderListItem) => (
<span className="text-text-primary">{item.name}</span>
),
},
{
key: 'status',
header: 'Status',
render: (item: WorkOrderListItem) => <Badge variant="info">{item.status}</Badge>,
},
{
key: 'contract',
header: 'Contrato',
render: (item: WorkOrderListItem) => (
<span className="text-text-secondary">{item.contract?.name ?? '—'}</span>
),
},
...(isVisible('totalUst')
? [
{
key: 'totalUst',
header: 'UST Total',
render: (item: WorkOrderListItem) => (
<Badge variant="info">{item.contractItem?.name ?? '—'}</Badge>
),
},
]
: []),
{
key: 'deliverables',
header: 'Entregáveis',
render: (item: WorkOrderListItem) => (
<span className="text-text-secondary">{item._count?.deliverables ?? 0}</span>
),
},
{
key: 'actions',
header: 'Ações',
render: (item: WorkOrderListItem) => (
<Link to={`/ordens-servico/${item.id}`} aria-label="Ver detalhes">
<Eye size={16} className="text-isis-blue" />
</Link>
),
},
];
return (
<PageContainer>
<PageHeader title="Gestão de Ordens de Serviço" breadcrumbs={breadcrumbs} />
<DataTable<WorkOrderListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhuma ordem de serviço encontrada"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="ordem"
itemLabelPlural="ordens"
onPageChange={setPage}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GestorContratoPage } from '../GestorContratoPage';
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
import type { UserRole } from '../../../types/auth.types';
const mockWorkOrders = {
data: [
{
id: 'wo-1',
code: 'OS-001',
name: 'Ordem de Serviço Gestão',
description: null,
status: 'EM_EXECUCAO',
reservedUst: 100,
totalValue: 99999,
startDate: '2026-01-01',
endDate: null,
isActive: true,
contract: { id: 'ct1', name: 'Contrato Gestão', clientId: 'c1' },
contractItem: { id: 'ci1', code: 'CI-001', name: 'Item Contrato', itemType: 'UST' },
_count: { deliverables: 5 },
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
],
total: 1,
page: 1,
limit: 20,
};
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrders: vi.fn(() => ({ data: mockWorkOrders, isLoading: false })),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: vi.fn(() => []),
}));
vi.mock('../../../modules/auth', () => ({
useAuth: vi.fn(() => ({
user: { id: '3', name: 'Gestor User', email: 'gestor@x.com', role: 'GESTOR_CONTRATO' as UserRole },
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
})),
}));
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PageTitleProvider>
<GestorContratoPage />
</PageTitleProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('GestorContratoPage', () => {
it('renders the work orders table with headers', () => {
renderPage();
expect(screen.getByText('Código')).toBeInTheDocument();
expect(screen.getByText('Nome')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Contrato')).toBeInTheDocument();
});
it('renders work order data', () => {
renderPage();
expect(screen.getByText('OS-001')).toBeInTheDocument();
expect(screen.getByText('Ordem de Serviço Gestão')).toBeInTheDocument();
expect(screen.getByText('Contrato Gestão')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('does not render totalValue or reservedUst as text', () => {
renderPage();
expect(screen.queryByText('99999')).not.toBeInTheDocument();
expect(screen.queryByText('100')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useCreateWorkOrder } from '../../hooks/useWorkOrders';
import { OrdemServicoForm } from './components/OrdemServicoForm';
import type { CreateWorkOrderRequest } from '../../types/work-order.types';
export function OrdemServicoCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const createMutation = useCreateWorkOrder();
async function handleSubmit(data: CreateWorkOrderRequest) {
setApiError(null);
try {
const result = await createMutation.mutateAsync(data);
showToast('Ordem de serviço criada com sucesso', 'success');
navigate(`/ordens-servico/${result.id}`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar ordem de serviço.');
} else {
setApiError('Erro ao criar ordem de serviço.');
}
}
}
return (
<PageContainer>
<PageHeader title="Nova OS" breadcrumbs={breadcrumbs} />
<FormCard title="Nova Ordem de Serviço">
<OrdemServicoForm
mode="create"
loading={createMutation.isPending}
apiError={apiError}
onSubmit={handleSubmit}
onCancel={() => navigate('/ordens-servico')}
/>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,232 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Pencil, XCircle, AlertTriangle, Info, DollarSign } from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { Button, Badge } from '../../components/ui';
import { Tabs, type TabItem } from '../../components/ui/Tabs';
import { useToast } from '../../components/ui/Toast';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useReadOnly } from '../../hooks/useReadOnly';
import { useFieldVisibility } from '../../hooks/useFieldVisibility';
import { useWorkOrder, useWorkOrderSummary, useCancelWorkOrder } from '../../hooks/useWorkOrders';
import { getWorkOrderStatusConfig } from '../../constants/work-order-status';
import { OrdemServicoSummaryTab } from './components/OrdemServicoSummaryTab';
import { OrdemServicoDeliverablesTab } from './components/OrdemServicoDeliverablesTab';
import { OrdemServicoStatusHistoryTab } from './components/OrdemServicoStatusHistoryTab';
import { CancelWorkOrderModal } from './components/CancelWorkOrderModal';
function formatNumber(value: number) {
return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
function formatPercent(value: number) {
return `${value.toFixed(1)}%`;
}
export function OrdemServicoDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { isReadOnly } = useReadOnly();
const isVisible = useFieldVisibility();
const [cancelOpen, setCancelOpen] = useState(false);
const { data: workOrder, isLoading, isError } = useWorkOrder(id ?? '');
const { data: summary } = useWorkOrderSummary(id ?? '');
const cancelMutation = useCancelWorkOrder();
const breadcrumbs = useBreadcrumbs({ ':name': workOrder?.code ?? '' });
if (isLoading) {
return (
<PageContainer>
<div className="flex items-center justify-center py-20">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !workOrder) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-20 text-text-secondary">
<Info className="h-10 w-10" />
<p className="text-lg font-medium">Ordem de serviço não encontrada</p>
<Button variant="secondary" onClick={() => navigate('/ordens-servico')}>
Voltar para lista
</Button>
</div>
</PageContainer>
);
}
const statusConfig = getWorkOrderStatusConfig(workOrder.status);
const isEditable = workOrder.status === 'RASCUNHO' || workOrder.status === 'EMITIDA';
const canCancel = workOrder.status === 'RASCUNHO' && (workOrder._count.deliverables ?? 0) === 0;
const tabs: TabItem[] = [
{ id: 'summary', label: 'Resumo' },
{ id: 'deliverables', label: 'Entregáveis' },
{ id: 'history', label: 'Histórico de status' },
];
async function handleCancel(observation: string) {
if (!id) return;
try {
await cancelMutation.mutateAsync({ id, observation });
showToast('Ordem de serviço cancelada', 'success');
setCancelOpen(false);
} catch {
showToast('Erro ao cancelar ordem de serviço', 'error');
}
}
return (
<PageContainer>
<PageHeader
title={`${workOrder.code}${workOrder.name}`}
breadcrumbs={breadcrumbs}
actions={
!isReadOnly ? (
<div className="flex gap-2">
{isEditable && (
<Button
variant="secondary"
icon={<Pencil size={14} />}
onClick={() => navigate(`/ordens-servico/${workOrder.id}/editar`)}
>
Editar
</Button>
)}
{canCancel && (
<Button
variant="danger"
icon={<XCircle size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancelar
</Button>
)}
</div>
) : undefined
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>
<span className="text-small text-text-secondary">Contrato: {workOrder.contract.name}</span>
<span className="text-small text-text-secondary">
Item: {workOrder.contractItem.code} {workOrder.contractItem.name}
</span>
</div>
{workOrder.description && <p className="mb-6 text-text-secondary">{workOrder.description}</p>}
{summary?.lowBalanceAlert && (
<div className="mb-4 flex items-center gap-2 rounded border border-warning/30 bg-warning/10 px-4 py-3 text-small text-warning">
<AlertTriangle size={16} />
<span>
Saldo baixo: menos de 20% disponível ({formatNumber(Number(summary.ustAvailable))} de{' '}
{formatNumber(Number(summary.ustReserved))} UST).
</span>
</div>
)}
{/* Card de Valoração — visível apenas para perfis com acesso financeiro */}
{isVisible('totalValue') && (
<div className="mb-6 rounded-lg border border-border bg-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-isis-blue/10">
<DollarSign className="h-5 w-5 text-isis-blue" />
</div>
<div className="flex flex-1 flex-col gap-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-text-muted">
Valor da Ordem de Serviço
</p>
<p className="text-2xl font-bold text-isis-blue">
{workOrder.totalValue != null
? formatCurrency(Number(workOrder.totalValue))
: 'Não calculado'}
</p>
{workOrder.contractItem.itemType === 'SAAS_LICENSE' &&
workOrder.contractItem.ustValue != null && (
<p className="text-small text-text-secondary">
{formatNumber(Number(workOrder.reservedUst))} licenças × R${' '}
{Number(workOrder.contractItem.ustValue).toLocaleString('pt-BR', {
minimumFractionDigits: 2,
})}
/unidade faturamento anual
</p>
)}
{workOrder.contractItem.itemType === 'UST' && (
<p className="text-small text-text-secondary">
Soma dos Entregáveis vinculados ({workOrder._count?.deliverables ?? 0} ativos)
</p>
)}
</div>
</div>
</div>
)}
{isVisible('reservedUst') && (
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">
{workOrder.contractItem.itemType === 'SAAS_LICENSE'
? 'Licenças reservadas'
: 'Pool reservado'}
</p>
<p className="text-lg font-semibold text-primary">
{formatNumber(Number(workOrder.reservedUst))}{' '}
{workOrder.contractItem.itemType === 'SAAS_LICENSE' ? 'licenças' : 'UST'}
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Pool consumido</p>
<p className="text-lg font-semibold text-primary">
{summary ? formatNumber(Number(summary.ustConsumed)) : '—'} UST
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Saldo disponível</p>
<p className="text-lg font-semibold text-primary">
{summary ? formatNumber(Number(summary.ustAvailable)) : '—'} UST
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">% de consumo</p>
<p className="text-lg font-semibold text-primary">
{summary ? formatPercent(Number(summary.consumptionPercent)) : '—'}
</p>
</div>
</div>
)}
<Tabs tabs={tabs} defaultTab="summary">
{(activeTabId: string) => (
<div className="pt-4">
{activeTabId === 'summary' && <OrdemServicoSummaryTab workOrderId={workOrder.id} />}
{activeTabId === 'deliverables' && (
<OrdemServicoDeliverablesTab workOrderId={workOrder.id} />
)}
{activeTabId === 'history' && (
<OrdemServicoStatusHistoryTab history={workOrder.statusHistory} />
)}
</div>
)}
</Tabs>
<CancelWorkOrderModal
open={cancelOpen}
loading={cancelMutation.isPending}
onConfirm={(obs) => void handleCancel(obs)}
onCancel={() => setCancelOpen(false)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useWorkOrder, useUpdateWorkOrder } from '../../hooks/useWorkOrders';
import { OrdemServicoForm } from './components/OrdemServicoForm';
import type { CreateWorkOrderRequest } from '../../types/work-order.types';
export function OrdemServicoEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: workOrder, isLoading, isError } = useWorkOrder(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':name': workOrder?.name ?? '' });
const updateMutation = useUpdateWorkOrder();
async function handleSubmit(data: CreateWorkOrderRequest) {
if (!id) return;
setApiError(null);
const updateData = {
code: data.code,
name: data.name,
description: data.description,
contractItemId: data.contractItemId,
projectIds: data.projectIds,
reservedUst: data.reservedUst,
startDate: data.startDate,
endDate: data.endDate,
};
try {
await updateMutation.mutateAsync({ id, data: updateData });
showToast('Ordem de serviço atualizada com sucesso', 'success');
navigate(`/ordens-servico/${id}`);
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao atualizar ordem de serviço.');
} else {
setApiError('Erro ao atualizar ordem de serviço.');
}
}
}
if (isLoading) {
return (
<PageContainer>
<PageHeader title="Editar OS" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !workOrder) {
return (
<PageContainer>
<PageHeader title="Editar OS" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Ordem de serviço não encontrada</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar OS" breadcrumbs={breadcrumbs} />
<FormCard title={`Editar ${workOrder.code}`}>
<OrdemServicoForm
mode="edit"
initialData={workOrder}
initialStatus={workOrder.status}
loading={updateMutation.isPending}
apiError={apiError}
onSubmit={handleSubmit}
onCancel={() => navigate('/ordens-servico')}
/>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,274 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye } from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
DataTable,
Pagination,
Tooltip,
Badge,
} from '../../components/ui';
import { useWorkOrders } from '../../hooks/useWorkOrders';
import { useClients } from '../../hooks/useClients';
import { useContracts } from '../../hooks/useContracts';
import { useProjects } from '../../hooks/useProjects';
import { useReadOnly } from '../../hooks/useReadOnly';
import {
WORK_ORDER_STATUS_OPTIONS,
getWorkOrderStatusConfig,
} from '../../constants/work-order-status';
import type {
WorkOrderListItem,
WorkOrdersFilters,
WorkOrderStatus,
} from '../../types/work-order.types';
const ITEMS_PER_PAGE = 20;
function formatNumber(value: number) {
return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
}
export function OrdensServicoListPage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const { isReadOnly } = useReadOnly();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [clientFilter, setClientFilter] = useState<string>('');
const [contractFilter, setContractFilter] = useState<string>('');
const [projectFilter, setProjectFilter] = useState<string>('');
const [page, setPage] = useState(1);
const filters: WorkOrdersFilters = useMemo(
() => ({
search: searchValue || undefined,
status: (statusFilter as WorkOrderStatus) || undefined,
clientId: clientFilter || undefined,
contractId: contractFilter || undefined,
projectId: projectFilter || undefined,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, clientFilter, contractFilter, projectFilter, page],
);
const { data, isLoading } = useWorkOrders(filters);
const { data: clientsData } = useClients({ page: 1, limit: 100 });
const { data: contractsData } = useContracts({ page: 1, limit: 100 });
const { data: projectsData } = useProjects({ page: 1, limit: 100 });
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const clientOptions = useMemo(
() => (clientsData?.data ?? []).map((c) => ({ value: c.id, label: c.name })),
[clientsData],
);
const contractOptions = useMemo(
() =>
(contractsData?.data ?? [])
.filter((c) => !clientFilter || c.client.id === clientFilter)
.map((c) => ({ value: c.id, label: c.name })),
[contractsData, clientFilter],
);
const projectOptions = useMemo(
() => (projectsData?.data ?? []).map((p) => ({ value: p.id, label: p.name })),
[projectsData],
);
const columns = useMemo(
() => [
{
key: 'code',
header: 'Código',
render: (wo: WorkOrderListItem) => (
<span className="text-primary font-medium">{wo.code}</span>
),
},
{
key: 'name',
header: 'Nome',
render: (wo: WorkOrderListItem) => <span className="text-primary">{wo.name}</span>,
},
{
key: 'contract',
header: 'Contrato',
render: (wo: WorkOrderListItem) => (
<span className="text-text-secondary">{wo.contract.name}</span>
),
},
{
key: 'contractItem',
header: 'Item',
render: (wo: WorkOrderListItem) => (
<span className="text-text-secondary">
{wo.contractItem.code} {wo.contractItem.name}
</span>
),
},
{
key: 'reservedUst',
header: 'Pool reservado',
render: (wo: WorkOrderListItem) => (
<span className="text-text-secondary">
{formatNumber(Number(wo.reservedUst))}{' '}
{wo.contractItem.itemType === 'SAAS_LICENSE' ? 'licenças' : 'UST'}
</span>
),
},
{
key: 'totalValue',
header: 'Total (R$)',
render: (wo: WorkOrderListItem) => (
<span className="text-text-secondary">
{wo.totalValue != null
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(
Number(wo.totalValue),
)
: '—'}
</span>
),
},
{
key: 'totalDeliverables',
header: 'Entregáveis',
render: (wo: WorkOrderListItem) => (
<span className="text-text-secondary">{wo._count?.deliverables ?? 0}</span>
),
},
{
key: 'status',
header: 'Status',
render: (wo: WorkOrderListItem) => {
const config = getWorkOrderStatusConfig(wo.status);
return <Badge variant={config.variant}>{config.label}</Badge>;
},
},
{
key: 'actions',
header: 'Ações',
render: (wo: WorkOrderListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/ordens-servico/${wo.id}`)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!isReadOnly && wo.status !== 'CANCELADA' && wo.status !== 'TOTALMENTE_PAGA' && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/ordens-servico/${wo.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate, isReadOnly],
);
return (
<PageContainer>
<PageHeader
title="Ordens de Serviço"
breadcrumbs={breadcrumbs}
actions={
!isReadOnly ? (
<Button onClick={() => navigate('/ordens-servico/nova')} icon={<Plus size={16} />}>
Nova OS
</Button>
) : undefined
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={(v) => {
setSearchValue(v);
setPage(1);
}}
placeholder="Buscar por código ou nome..."
/>
</div>
<Select
options={WORK_ORDER_STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
/>
{!isReadOnly && (
<Select
options={clientOptions}
placeholder="Todos os clientes"
value={clientFilter}
onChange={(e) => {
setClientFilter(e.target.value);
setContractFilter('');
setPage(1);
}}
/>
)}
<Select
options={contractOptions}
placeholder="Todos os contratos"
value={contractFilter}
onChange={(e) => {
setContractFilter(e.target.value);
setPage(1);
}}
/>
<Select
options={projectOptions}
placeholder="Todos os projetos"
value={projectFilter}
onChange={(e) => {
setProjectFilter(e.target.value);
setPage(1);
}}
/>
</div>
<DataTable<WorkOrderListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhuma ordem de serviço encontrada"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="ordem de serviço"
itemLabelPlural="ordens de serviço"
onPageChange={setPage}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OrdemServicoCreatePage } from '../OrdemServicoCreatePage';
const mutateAsyncMock = vi.fn();
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrders: () => ({ data: { data: [] } }),
useCreateWorkOrder: () => ({ mutateAsync: mutateAsyncMock, isPending: false }),
useWorkOrder: () => ({ data: undefined }),
}));
vi.mock('../../../hooks/useClients', () => ({
useClients: () => ({ data: { data: [{ id: 'cli1', name: 'Cliente A' }] } }),
}));
vi.mock('../../../hooks/useContracts', () => ({
useContracts: () => ({
data: { data: [{ id: 'c1', name: 'Contrato A', client: { id: 'cli1' } }] },
}),
}));
vi.mock('../../../hooks/useContractItems', () => ({
useClientActiveContractItems: () => ({
data: [
{ id: 'ic1', code: 'IC-1', name: 'Item UST', itemType: 'UST', totalUst: 5000 },
{ id: 'ic2', code: 'IC-2', name: 'Item SaaS', itemType: 'SAAS_LICENSE', totalUst: 100 },
],
}),
}));
vi.mock('../../../hooks/useProjects', () => ({
useProjects: () => ({
data: { data: [{ id: 'p1', name: 'Projeto X', contract: { id: 'c1' } }] },
}),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: () => ({ isReadOnly: false }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [{ label: 'Dashboard', to: '/' }],
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<OrdemServicoCreatePage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('OrdemServicoCreatePage', () => {
it('renderiza form com selects encadeados', () => {
renderPage();
expect(screen.getByText('Cliente')).toBeInTheDocument();
expect(screen.getByText('Contrato')).toBeInTheDocument();
expect(screen.getByText('Item de Contrato')).toBeInTheDocument();
expect(screen.getByText('Projetos *')).toBeInTheDocument();
expect(screen.getByText('USTs reservadas')).toBeInTheDocument();
});
it('exibe placeholders dependentes de cliente/contrato', () => {
renderPage();
expect(screen.getAllByText('Selecione um cliente primeiro').length).toBeGreaterThan(0);
});
it('exibe campo Projetos vinculado ao contrato', () => {
renderPage();
expect(screen.getByText('Selecione um contrato primeiro')).toBeInTheDocument();
});
it('Zod bloqueia submit incompleto', async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('button', { name: /Salvar/i }));
expect(mutateAsyncMock).not.toHaveBeenCalled();
});
it('exibe ambos os tipos (UST e SaaS) no select de Item de Contrato', () => {
renderPage();
expect(
screen.queryByText('Apenas itens do tipo UST podem ter Ordens de Serviço'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OrdemServicoDetailPage } from '../OrdemServicoDetailPage';
const mockWorkOrder = {
id: 'wo-1',
code: 'OS-001',
name: 'OS Pilotina',
description: 'Descrição da OS',
status: 'EMITIDA' as const,
reservedUst: 1000,
startDate: '2026-01-01',
endDate: null,
isActive: true,
contractId: 'c1',
contractItemId: 'ic1',
contract: { id: 'c1', name: 'Contrato A', code: 'CT-001', clientId: 'cli1' },
contractItem: { id: 'ic1', code: 'IC-1', name: 'Item 1', totalUst: 5000 },
projects: [{ id: 'p1', name: 'Projeto X', code: 'P-001' }],
deliverableCounts: [],
statusHistory: [
{
id: 'h-1',
previousStatus: 'RASCUNHO' as const,
newStatus: 'EMITIDA' as const,
observation: null,
createdAt: '2026-01-02T00:00:00.000Z',
createdBy: 'u1',
},
],
_count: { deliverables: 2 },
createdAt: '2026-01-01',
updatedAt: '2026-01-02',
createdBy: 'u1',
updatedBy: 'u1',
};
const mockSummary = {
workOrderId: 'wo-1',
totalDeliverables: 2,
deliverablesByStatus: { EM_EXECUCAO: 2 },
ustReserved: 1000,
ustInExecution: 200,
ustPaid: 0,
ustConsumed: 200,
ustAvailable: 800,
consumptionPercent: 20,
valueReserved: 100000,
valueConsumed: 20000,
valueAvailable: 80000,
lowBalanceAlert: false,
};
const useWorkOrderMock = vi.fn();
const useSummaryMock = vi.fn();
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrder: (...args: unknown[]) => useWorkOrderMock(...args),
useWorkOrderSummary: (...args: unknown[]) => useSummaryMock(...args),
useCancelWorkOrder: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverables: () => ({ data: { data: [], total: 0, page: 1, limit: 20 }, isLoading: false }),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: () => ({ isReadOnly: false }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [{ label: 'Dashboard', to: '/' }],
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
vi.mock('../../../modules/auth', () => ({
useAuth: () => ({
user: { id: '1', name: 'Admin', email: 'admin@test.com', role: 'ADMIN' },
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
}),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/ordens-servico/wo-1']}>
<Routes>
<Route path="/ordens-servico/:id" element={<OrdemServicoDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('OrdemServicoDetailPage', () => {
beforeEach(() => {
useWorkOrderMock.mockReturnValue({ data: mockWorkOrder, isLoading: false, isError: false });
useSummaryMock.mockReturnValue({ data: mockSummary });
});
it('renderiza header com status e descrição', () => {
renderPage();
expect(screen.getByText('Emitida')).toBeInTheDocument();
expect(screen.getByText(/Contrato A/)).toBeInTheDocument();
expect(screen.getByText(/IC-1/)).toBeInTheDocument();
expect(screen.getByText('Descrição da OS')).toBeInTheDocument();
});
it('exibe cards de indicadores', () => {
renderPage();
expect(screen.getByText('Pool reservado')).toBeInTheDocument();
expect(screen.getByText('Pool consumido')).toBeInTheDocument();
expect(screen.getByText('Saldo disponível')).toBeInTheDocument();
expect(screen.getByText('% de consumo')).toBeInTheDocument();
});
it('alterna entre abas', async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('tab', { name: /Histórico de status/i }));
expect(screen.getAllByText('Emitida').length).toBeGreaterThan(1);
});
it('exibe alerta de saldo baixo quando lowBalanceAlert true', () => {
useSummaryMock.mockReturnValue({ data: { ...mockSummary, lowBalanceAlert: true } });
renderPage();
expect(screen.getByText(/Saldo baixo/)).toBeInTheDocument();
});
it('botão Cancelar só aparece em RASCUNHO sem deliverables', () => {
useWorkOrderMock.mockReturnValue({
data: { ...mockWorkOrder, status: 'RASCUNHO' as const, _count: { deliverables: 0 } },
isLoading: false,
isError: false,
});
renderPage();
expect(screen.getByRole('button', { name: /Cancelar/i })).toBeInTheDocument();
});
it('botão Cancelar não aparece em outros status', () => {
renderPage();
expect(screen.queryByRole('button', { name: /^Cancelar$/i })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OrdemServicoDetailPage } from '../OrdemServicoDetailPage';
import type { UserRole } from '../../../types/auth.types';
const mockWorkOrder = {
id: 'wo-1',
code: 'OS-001',
name: 'OS Pilotina',
description: null,
status: 'EMITIDA' as const,
reservedUst: 1000,
totalValue: 99999,
startDate: '2026-01-01',
endDate: null,
isActive: true,
contractId: 'c1',
contractItemId: 'ic1',
contract: { id: 'c1', name: 'Contrato A', code: 'CT-001', clientId: 'cli1' },
contractItem: { id: 'ic1', code: 'IC-1', name: 'Item 1', totalUst: 5000, itemType: 'UST', ustValue: null },
projects: [],
statusHistory: [],
_count: { deliverables: 2 },
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
};
const mockSummary = {
workOrderId: 'wo-1',
totalDeliverables: 2,
deliverablesByStatus: {},
ustReserved: 1000,
ustConsumed: 200,
ustAvailable: 800,
consumptionPercent: 20,
valueReserved: 100000,
valueConsumed: 20000,
valueAvailable: 80000,
lowBalanceAlert: false,
};
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrder: vi.fn(() => ({ data: mockWorkOrder, isLoading: false, isError: false })),
useWorkOrderSummary: vi.fn(() => ({ data: mockSummary })),
useCancelWorkOrder: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverables: () => ({ data: { data: [], total: 0, page: 1, limit: 20 }, isLoading: false }),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: () => ({ isReadOnly: true }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: vi.fn(() => []),
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
vi.mock('../../../components/ui/Toast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
const mockUseAuth = vi.fn();
vi.mock('../../../modules/auth', () => ({
useAuth: (...args: unknown[]) => mockUseAuth(...args),
}));
function makeUser(role: UserRole) {
return { id: '1', name: 'User', email: 'user@test.com', role };
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/ordens-servico/wo-1']}>
<Routes>
<Route path="/ordens-servico/:id" element={<OrdemServicoDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
mockUseAuth.mockReturnValue({ user: makeUser('ADMIN'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
});
describe('OrdemServicoDetailPage — field visibility', () => {
it('ADMIN sees the financial card (Valor da Ordem)', () => {
mockUseAuth.mockReturnValue({ user: makeUser('ADMIN'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.getByText('Valor da Ordem de Serviço')).toBeInTheDocument();
expect(screen.getByText('Pool reservado')).toBeInTheDocument();
});
it('PO does not see the financial card', () => {
mockUseAuth.mockReturnValue({ user: makeUser('PO'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor da Ordem de Serviço')).not.toBeInTheDocument();
expect(screen.queryByText('Pool reservado')).not.toBeInTheDocument();
});
it('FISCAL_CONTRATO does not see the financial card', () => {
mockUseAuth.mockReturnValue({ user: makeUser('FISCAL_CONTRATO'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor da Ordem de Serviço')).not.toBeInTheDocument();
});
it('GESTOR_PROJETOS does not see the financial card', () => {
mockUseAuth.mockReturnValue({ user: makeUser('GESTOR_PROJETOS'), isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn() });
renderPage();
expect(screen.queryByText('Valor da Ordem de Serviço')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OrdensServicoListPage } from '../OrdensServicoListPage';
const mockData = {
data: [
{
id: 'wo-1',
code: 'OS-001',
name: 'OS Pilotina',
description: null,
status: 'EMITIDA' as const,
reservedUst: 1000,
startDate: '2026-01-01',
endDate: null,
isActive: true,
contract: { id: 'c1', name: 'Contrato A', clientId: 'cli1' },
contractItem: { id: 'ic1', code: 'IC-1', name: 'Item 1' },
_count: { deliverables: 3 },
createdAt: '2026-01-01',
updatedAt: '2026-01-02',
},
],
total: 1,
page: 1,
limit: 20,
};
vi.mock('../../../hooks/useWorkOrders', () => ({
useWorkOrders: () => ({ data: mockData, isLoading: false }),
}));
vi.mock('../../../hooks/useClients', () => ({
useClients: () => ({ data: { data: [{ id: 'cli1', name: 'Cliente A' }] } }),
}));
vi.mock('../../../hooks/useContracts', () => ({
useContracts: () => ({
data: { data: [{ id: 'c1', name: 'Contrato A', client: { id: 'cli1' } }] },
}),
}));
vi.mock('../../../hooks/useProjects', () => ({
useProjects: () => ({ data: { data: [{ id: 'p1', name: 'Projeto X' }] } }),
}));
vi.mock('../../../hooks/useReadOnly', () => ({
useReadOnly: () => ({ isReadOnly: false }),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: () => [{ label: 'Dashboard', to: '/' }],
}));
vi.mock('../../../hooks/usePageTitle', () => ({
usePageTitle: () => ({ setPageTitle: vi.fn() }),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<OrdensServicoListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('OrdensServicoListPage', () => {
it('renderiza tabela com colunas e dados', () => {
renderPage();
expect(screen.getByText('OS-001')).toBeInTheDocument();
expect(screen.getByText('OS Pilotina')).toBeInTheDocument();
expect(screen.getAllByText('Contrato A').length).toBeGreaterThan(0);
expect(screen.getByText(/IC-1/)).toBeInTheDocument();
});
it('exibe filtros de status, cliente, contrato, projeto', () => {
renderPage();
expect(screen.getByText('Todos os status')).toBeInTheDocument();
expect(screen.getByText('Todos os clientes')).toBeInTheDocument();
expect(screen.getByText('Todos os contratos')).toBeInTheDocument();
expect(screen.getByText('Todos os projetos')).toBeInTheDocument();
});
it('exibe busca por código/nome', () => {
renderPage();
expect(screen.getByPlaceholderText(/Buscar por código ou nome/)).toBeInTheDocument();
});
it('renderiza botão "Nova OS" quando não read-only', () => {
renderPage();
expect(screen.getByRole('button', { name: /Nova OS/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../../../components/ui';
interface Props {
open: boolean;
loading?: boolean;
onConfirm: (observation: string) => void;
onCancel: () => void;
}
export function CancelWorkOrderModal({ open, loading, onConfirm, onCancel }: Props) {
const [observation, setObservation] = useState('');
useEffect(() => {
if (!open) setObservation('');
}, [open]);
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">Cancelar Ordem de Serviço</h2>
<p className="mb-4 text-body text-text-secondary">
A OS será marcada como CANCELADA. Esta ação é irreversível.
</p>
<label className="block text-small font-medium text-primary mb-1">
Observação <span className="text-danger">*</span>
</label>
<textarea
value={observation}
onChange={(e) => setObservation(e.target.value)}
rows={3}
className="w-full rounded border border-border-default bg-background px-3 py-2 text-body text-primary focus:border-isis-blue focus:outline-none focus:ring-1 focus:ring-isis-blue"
placeholder="Motivo do cancelamento"
/>
<div className="mt-6 flex justify-end gap-3">
<Button variant="secondary" onClick={onCancel} disabled={loading}>
Voltar
</Button>
<Button
variant="danger"
onClick={() => onConfirm(observation.trim())}
loading={loading}
disabled={!observation.trim()}
>
Confirmar cancelamento
</Button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye } from 'lucide-react';
import { Button, DataTable, Pagination, Tooltip } from '../../../components/ui';
import { StatusBadge } from '../../../components/shared/StatusBadge';
import { useDeliverables } from '../../../hooks/useDeliverables';
import { useReadOnly } from '../../../hooks/useReadOnly';
import { DELIVERABLE_TYPE_LABELS } from '../../../constants/deliverable-type';
import type { DeliverableListItem } from '../../../types/deliverable.types';
const ITEMS_PER_PAGE = 20;
interface Props {
workOrderId: string;
}
export function OrdemServicoDeliverablesTab({ workOrderId }: Props) {
const navigate = useNavigate();
const { isReadOnly } = useReadOnly();
const [page, setPage] = useState(1);
const { data, isLoading } = useDeliverables({
workOrderId,
page,
limit: ITEMS_PER_PAGE,
});
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const columns = useMemo(
() => [
{
key: 'code',
header: 'Código',
render: (d: DeliverableListItem) => (
<span className="text-primary font-medium">{d.code}</span>
),
},
{
key: 'title',
header: 'Título',
render: (d: DeliverableListItem) => <span className="text-primary">{d.title}</span>,
},
{
key: 'type',
header: 'Tipo',
render: (d: DeliverableListItem) => (
<span className="text-text-secondary">{DELIVERABLE_TYPE_LABELS[d.type] ?? d.type}</span>
),
},
{
key: 'status',
header: 'Status',
render: (d: DeliverableListItem) => <StatusBadge status={d.status} />,
},
{
key: 'project',
header: 'Projeto',
render: (d: DeliverableListItem) => (
<span className="text-text-secondary">{d.project.name}</span>
),
},
{
key: 'sprint',
header: 'Sprint',
render: (d: DeliverableListItem) => (
<span className="text-text-secondary">{d.sprint?.name ?? '—'}</span>
),
},
{
key: 'actions',
header: 'Ações',
render: (d: DeliverableListItem) => (
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/entregaveis/${d.id}`)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
),
},
],
[navigate],
);
return (
<div className="space-y-4">
{!isReadOnly && (
<div className="flex justify-end">
<Button
onClick={() => navigate(`/entregaveis/novo?workOrderId=${workOrderId}`)}
icon={<Plus size={16} />}
>
Adicionar Entregável
</Button>
</div>
)}
<DataTable<DeliverableListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum entregável vinculado a esta OS"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="entregável"
itemLabelPlural="entregáveis"
onPageChange={setPage}
/>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Input, Select, DatePickerField } from '../../../components/ui';
import { useClients } from '../../../hooks/useClients';
import { useContracts } from '../../../hooks/useContracts';
import { useClientActiveContractItems } from '../../../hooks/useContractItems';
import { useProjects } from '../../../hooks/useProjects';
import { useWorkOrders } from '../../../hooks/useWorkOrders';
import type {
CreateWorkOrderRequest,
WorkOrderDetail,
WorkOrderStatus,
} from '../../../types/work-order.types';
const formSchema = z
.object({
clientId: z.string().uuid('Cliente é obrigatório'),
contractId: z.string().uuid('Contrato é obrigatório'),
contractItemId: z.string().uuid('Item de contrato é obrigatório'),
projectIds: z.array(z.string().uuid()).min(1, 'Selecione ao menos um projeto'),
code: z.string().min(1, 'Código é obrigatório'),
name: z.string().min(1, 'Nome é obrigatório'),
description: z.string().optional(),
reservedUst: z
.number({ message: 'UST reservada é obrigatória' })
.positive('UST reservada deve ser maior que zero'),
startDate: z.string().min(1, 'Data de início é obrigatória'),
endDate: z.string().optional(),
})
.refine(
(data) => {
if (data.endDate && data.endDate.length > 0) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{ message: 'Data de término deve ser igual ou posterior à data de início', path: ['endDate'] },
);
export type WorkOrderFormData = z.infer<typeof formSchema>;
export interface OrdemServicoFormProps {
mode: 'create' | 'edit';
initialData?: WorkOrderDetail;
initialStatus?: WorkOrderStatus;
loading?: boolean;
apiError?: string | null;
onSubmit: (data: CreateWorkOrderRequest) => Promise<void>;
onCancel: () => void;
}
function toDateInput(value: string | null | undefined): string {
if (!value) return '';
return value.slice(0, 10);
}
export function OrdemServicoForm({
mode,
initialData,
initialStatus,
loading,
apiError,
onSubmit,
onCancel,
}: OrdemServicoFormProps) {
const isContractItemEditable = mode === 'create' || initialStatus === 'RASCUNHO';
const {
register,
handleSubmit,
watch,
setValue,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<WorkOrderFormData>({
resolver: zodResolver(formSchema),
defaultValues: {
projectIds: [],
},
});
const selectedClientId = watch('clientId');
const selectedContractId = watch('contractId');
const selectedContractItemId = watch('contractItemId');
const selectedProjectIds = watch('projectIds') ?? [];
const reservedUstValue = watch('reservedUst');
const { data: clientsData } = useClients({ isActive: 'true', page: 1, limit: 100 });
const { data: contractsData } = useContracts({ isActive: 'true', page: 1, limit: 100 });
const { data: contractItemsData } = useClientActiveContractItems(selectedClientId ?? '');
const { data: projectsData } = useProjects({ isActive: 'true', page: 1, limit: 100 });
const { data: existingWorkOrders } = useWorkOrders({
contractItemId: selectedContractItemId || undefined,
isActive: true,
page: 1,
limit: 100,
});
useEffect(() => {
if (initialData) {
reset({
clientId: initialData.contract.clientId,
contractId: initialData.contractId,
contractItemId: initialData.contractItemId,
projectIds: initialData.projects.map((p) => p.id),
code: initialData.code,
name: initialData.name,
description: initialData.description ?? '',
reservedUst: Number(initialData.reservedUst),
startDate: toDateInput(initialData.startDate),
endDate: toDateInput(initialData.endDate),
});
}
}, [initialData, reset]);
const clientOptions = useMemo(
() => (clientsData?.data ?? []).map((c) => ({ value: c.id, label: c.name })),
[clientsData],
);
const contractOptions = useMemo(
() =>
(contractsData?.data ?? [])
.filter((c) => c.client.id === selectedClientId)
.map((c) => ({ value: c.id, label: c.name })),
[contractsData, selectedClientId],
);
const contractItemOptions = useMemo(
() =>
(contractItemsData ?? []).map((ci) => ({
value: ci.id,
label: `${ci.code}${ci.name} (${ci.itemType === 'SAAS_LICENSE' ? 'Licença SaaS' : 'UST'})`,
})),
[contractItemsData],
);
const projectsForContract = useMemo(
() => (projectsData?.data ?? []).filter((p) => p.contract.id === selectedContractId),
[projectsData, selectedContractId],
);
const selectedItem = useMemo(
() => (contractItemsData ?? []).find((ci) => ci.id === selectedContractItemId),
[contractItemsData, selectedContractItemId],
);
const reservedByOthers = useMemo(() => {
if (!existingWorkOrders) return 0;
return existingWorkOrders.data
.filter((wo) => wo.status !== 'CANCELADA' && (mode === 'create' || wo.id !== initialData?.id))
.reduce((sum, wo) => sum + Number(wo.reservedUst), 0);
}, [existingWorkOrders, mode, initialData?.id]);
const itemBalance = selectedItem ? Number(selectedItem.totalUst) - reservedByOthers : null;
const requested = Number(reservedUstValue) || 0;
const exceedsBalance = itemBalance !== null && requested > itemBalance;
function handleClientChange(e: React.ChangeEvent<HTMLSelectElement>) {
register('clientId').onChange(e);
setValue('contractId', '' as unknown as string, { shouldValidate: false });
setValue('contractItemId', '' as unknown as string, { shouldValidate: false });
setValue('projectIds', [], { shouldValidate: false });
}
function handleContractChange(e: React.ChangeEvent<HTMLSelectElement>) {
register('contractId').onChange(e);
setValue('projectIds', [], { shouldValidate: false });
}
function toggleProject(projectId: string, checked: boolean) {
const next = checked
? [...selectedProjectIds, projectId]
: selectedProjectIds.filter((id) => id !== projectId);
setValue('projectIds', next, { shouldValidate: true });
}
async function handleFormSubmit(data: WorkOrderFormData) {
await onSubmit({
code: data.code,
name: data.name,
description: data.description || undefined,
contractId: data.contractId,
contractItemId: data.contractItemId,
projectIds: data.projectIds,
reservedUst: data.reservedUst,
startDate: data.startDate,
endDate: data.endDate || undefined,
});
}
return (
<form onSubmit={(e) => void handleSubmit(handleFormSubmit)(e)} noValidate className="space-y-5">
<div className={mode === 'edit' ? 'pointer-events-none opacity-60' : undefined}>
<Select
label="Cliente"
options={clientOptions}
placeholder="Selecione um cliente"
error={errors.clientId?.message}
tabIndex={mode === 'edit' ? -1 : undefined}
value={watch('clientId') ?? ''}
{...register('clientId')}
onChange={handleClientChange}
/>
</div>
<div className={mode === 'edit' ? 'pointer-events-none opacity-60' : undefined}>
<Select
label="Contrato"
options={contractOptions}
placeholder={selectedClientId ? 'Selecione um contrato' : 'Selecione um cliente primeiro'}
error={errors.contractId?.message}
disabled={!selectedClientId && mode === 'create'}
tabIndex={mode === 'edit' ? -1 : undefined}
value={watch('contractId') ?? ''}
{...register('contractId')}
onChange={handleContractChange}
/>
</div>
<div>
<div
className={
mode === 'edit' && !isContractItemEditable
? 'pointer-events-none opacity-60'
: undefined
}
>
<Select
label="Item de Contrato"
options={contractItemOptions}
placeholder={selectedClientId ? 'Selecione um item' : 'Selecione um cliente primeiro'}
error={errors.contractItemId?.message}
disabled={!selectedClientId && mode === 'create'}
tabIndex={mode === 'edit' && !isContractItemEditable ? -1 : undefined}
value={watch('contractItemId') ?? ''}
{...register('contractItemId')}
/>
</div>
{selectedItem && (
<p className="mt-1 text-small text-text-secondary">
Tipo:{' '}
<strong>{selectedItem.itemType === 'SAAS_LICENSE' ? 'Licença SaaS' : 'UST'}</strong>
</p>
)}
{!isContractItemEditable && (
<p className="mt-1 text-small text-warning">
Item de contrato pode ser alterado em OS em RASCUNHO
</p>
)}
</div>
<div>
<label className="block text-small font-medium text-primary mb-2">Projetos *</label>
{!selectedContractId && (
<p className="text-small text-text-muted">Selecione um contrato primeiro</p>
)}
{selectedContractId && projectsForContract.length === 0 && (
<p className="text-small text-text-muted">Nenhum projeto disponível para este contrato</p>
)}
{selectedContractId && projectsForContract.length > 0 && (
<div className="space-y-2 rounded border border-border-default p-3">
{projectsForContract.map((p) => (
<label key={p.id} className="flex items-center gap-2 text-body text-primary">
<input
type="checkbox"
checked={selectedProjectIds.includes(p.id)}
onChange={(e) => toggleProject(p.id, e.target.checked)}
className="h-4 w-4 rounded border-border-default text-isis-blue focus:ring-isis-blue"
/>
<span>{p.name}</span>
</label>
))}
</div>
)}
{errors.projectIds && (
<p className="mt-1 text-small text-danger">{errors.projectIds.message}</p>
)}
</div>
<Input
label="Código"
placeholder="OS-2026-001"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Nome"
placeholder="Nome da OS"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Descrição"
placeholder="Descrição opcional"
error={errors.description?.message}
{...register('description')}
/>
<div>
<Input
label={
selectedItem?.itemType === 'SAAS_LICENSE' ? 'Quantidade de licenças' : 'USTs reservadas'
}
type="number"
step="0.01"
min="0"
error={errors.reservedUst?.message}
{...register('reservedUst', { valueAsNumber: true })}
/>
{selectedItem && itemBalance !== null && (
<p
className={`mt-1 text-small ${exceedsBalance ? 'text-danger' : 'text-text-secondary'}`}
>
Saldo do item: {itemBalance.toLocaleString('pt-BR')}{' '}
{selectedItem.itemType === 'SAAS_LICENSE' ? 'licenças' : 'UST'} (total{' '}
{Number(selectedItem.totalUst).toLocaleString('pt-BR')} reservado{' '}
{reservedByOthers.toLocaleString('pt-BR')})
{exceedsBalance && ' — excede saldo disponível'}
</p>
)}
{selectedItem?.itemType === 'SAAS_LICENSE' &&
selectedItem.ustValue != null &&
requested > 0 && (
<p className="mt-1 text-small text-isis-blue">
Valor da OS:{' '}
{(requested * selectedItem.ustValue).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}{' '}
({requested} licenças × R${' '}
{selectedItem.ustValue.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}/unidade
faturamento anual)
</p>
)}
{selectedItem?.itemType === 'UST' && (
<p className="mt-1 text-small text-text-muted">
Total computado a partir dos Entregáveis vinculados.
</p>
)}
</div>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting || loading}>
{isSubmitting || loading ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge } from '../../../components/ui';
import { getWorkOrderStatusConfig } from '../../../constants/work-order-status';
import type { WorkOrderStatusHistoryItem } from '../../../types/work-order.types';
interface Props {
history: WorkOrderStatusHistoryItem[] | undefined;
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString('pt-BR');
}
export function OrdemServicoStatusHistoryTab({ history }: Props) {
const sorted = (history ?? [])
.slice()
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (sorted.length === 0) {
return <p className="py-8 text-text-muted">Nenhuma transição registrada.</p>;
}
return (
<ul className="space-y-3">
{sorted.map((entry) => {
const newConfig = getWorkOrderStatusConfig(entry.newStatus);
const previousConfig = entry.previousStatus
? getWorkOrderStatusConfig(entry.previousStatus)
: null;
return (
<li key={entry.id} className="rounded border border-border-default p-4">
<div className="flex flex-wrap items-center gap-2 text-small">
{previousConfig && (
<>
<Badge variant={previousConfig.variant}>{previousConfig.label}</Badge>
<span className="text-text-muted"></span>
</>
)}
<Badge variant={newConfig.variant}>{newConfig.label}</Badge>
<span className="ml-auto text-text-muted">{formatDateTime(entry.createdAt)}</span>
</div>
{entry.observation && (
<p className="mt-2 text-body text-text-secondary">{entry.observation}</p>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,104 @@
import { useWorkOrderSummary } from '../../../hooks/useWorkOrders';
import { getStatusConfig } from '../../../constants/deliverable-status';
import { Badge } from '../../../components/ui';
import type { DeliverableStatus } from '../../../types/deliverable.types';
interface Props {
workOrderId: string;
}
function formatNumber(value: number) {
return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
export function OrdemServicoSummaryTab({ workOrderId }: Props) {
const { data: summary, isLoading } = useWorkOrderSummary(workOrderId);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
);
}
if (!summary) {
return <p className="py-8 text-text-muted">Indicadores não disponíveis</p>;
}
const statusEntries = Object.entries(summary.deliverablesByStatus);
return (
<div className="space-y-6">
<section>
<h3 className="mb-3 text-body font-semibold text-primary">Distribuição por status</h3>
{statusEntries.length === 0 ? (
<p className="text-text-muted">Nenhum entregável vinculado.</p>
) : (
<div className="flex flex-wrap gap-2">
{statusEntries.map(([status, count]) => {
const config = getStatusConfig(status as DeliverableStatus);
return (
<Badge key={status} variant={config.variant}>
{config.label}: {count}
</Badge>
);
})}
</div>
)}
</section>
<section>
<h3 className="mb-3 text-body font-semibold text-primary">UST</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Reservada</p>
<p className="text-lg font-semibold text-primary">
{formatNumber(Number(summary.ustReserved))}
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Em execução</p>
<p className="text-lg font-semibold text-primary">
{formatNumber(Number(summary.ustInExecution))}
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Paga</p>
<p className="text-lg font-semibold text-primary">
{formatNumber(Number(summary.ustPaid))}
</p>
</div>
</div>
</section>
<section>
<h3 className="mb-3 text-body font-semibold text-primary">Valores</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Reservado</p>
<p className="text-lg font-semibold text-primary">
{formatCurrency(Number(summary.valueReserved))}
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Consumido</p>
<p className="text-lg font-semibold text-primary">
{formatCurrency(Number(summary.valueConsumed))}
</p>
</div>
<div className="rounded border border-border-default p-4">
<p className="text-small text-text-muted">Disponível</p>
<p className="text-lg font-semibold text-primary">
{formatCurrency(Number(summary.valueAvailable))}
</p>
</div>
</div>
</section>
</div>
);
}

97
src/pages/po/PoPage.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { DataTable, Pagination, Badge } from '../../components/ui';
import { StatusBadge } from '../../components/shared/StatusBadge';
import { useDeliverables } from '../../hooks/useDeliverables';
import { useFieldVisibility } from '../../hooks/useFieldVisibility';
import { DELIVERABLE_TYPE_LABELS } from '../../constants/deliverable-type';
import type { DeliverableListItem } from '../../types/deliverable.types';
const ITEMS_PER_PAGE = 20;
export function PoPage() {
const breadcrumbs = useBreadcrumbs();
const isVisible = useFieldVisibility();
const [page, setPage] = useState(1);
const { data, isLoading } = useDeliverables({ page, limit: ITEMS_PER_PAGE });
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
const columns = [
{
key: 'code',
header: 'Código',
render: (item: DeliverableListItem) => (
<Link to={`/entregaveis/${item.id}`} className="text-isis-blue hover:underline font-medium">
{item.code}
</Link>
),
},
{
key: 'title',
header: 'Título',
render: (item: DeliverableListItem) => (
<span className="text-text-primary">{item.title}</span>
),
},
{
key: 'status',
header: 'Status',
render: (item: DeliverableListItem) => <StatusBadge status={item.status} />,
},
{
key: 'type',
header: 'Tipo',
render: (item: DeliverableListItem) => (
<Badge variant="info">{DELIVERABLE_TYPE_LABELS[item.type]}</Badge>
),
},
...(isVisible('project')
? [
{
key: 'project',
header: 'Projeto',
render: (item: DeliverableListItem) => (
<span className="text-text-secondary">{item.project?.name ?? '—'}</span>
),
},
]
: []),
{
key: 'actions',
header: 'Ações',
render: (item: DeliverableListItem) => (
<Link to={`/entregaveis/${item.id}`} aria-label="Ver detalhes">
<Eye size={16} className="text-isis-blue" />
</Link>
),
},
];
return (
<PageContainer>
<PageHeader title="Meus Entregáveis" breadcrumbs={breadcrumbs} />
<DataTable<DeliverableListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum entregável encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="entregável"
itemLabelPlural="entregáveis"
onPageChange={setPage}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PoPage } from '../PoPage';
import { PageTitleProvider } from '../../../modules/page-title/PageTitleContext';
import type { UserRole } from '../../../types/auth.types';
const mockDeliverables = {
data: [
{
id: 'del-1',
code: 'ENT-001',
title: 'Entregável PO',
status: 'EM_EXECUCAO',
type: 'DESENVOLVIMENTO',
workOrderId: 'wo-1',
workOrder: null,
client: { id: 'c1', name: 'Empresa X' },
contract: { id: 'ct1', name: 'Contrato 1' },
project: { id: 'p1', name: 'Projeto Alpha' },
contractItem: { id: 'ci1', name: 'Item 1', code: 'CI-001', itemType: 'UST', ustValue: null, timeboxDescoberta: null, timeboxDesign: null, timeboxArquitetura: null, timeboxConstrucao: null, timeboxManutencao: null },
ustQuantity: null,
totalValue: 9999,
numWeeks: 4,
startDate: '2026-01-01',
expectedEndDate: '2026-03-01',
isActive: true,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
],
total: 1,
page: 1,
limit: 20,
};
vi.mock('../../../hooks/useDeliverables', () => ({
useDeliverables: vi.fn(() => ({ data: mockDeliverables, isLoading: false })),
}));
vi.mock('../../../hooks/useBreadcrumbs', () => ({
useBreadcrumbs: vi.fn(() => []),
}));
vi.mock('../../../modules/auth', () => ({
useAuth: vi.fn(() => ({
user: { id: '1', name: 'PO User', email: 'po@x.com', role: 'PO' as UserRole },
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
})),
}));
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PageTitleProvider>
<PoPage />
</PageTitleProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('PoPage', () => {
it('renders the deliverables table with headers', () => {
renderPage();
expect(screen.getByText('Código')).toBeInTheDocument();
expect(screen.getByText('Título')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
});
it('renders deliverable code and title', () => {
renderPage();
expect(screen.getByText('ENT-001')).toBeInTheDocument();
expect(screen.getByText('Entregável PO')).toBeInTheDocument();
});
it('does not render financial fields (totalValue, numWeeks)', () => {
renderPage();
expect(screen.queryByText('9999')).not.toBeInTheDocument();
expect(screen.queryByText('R$ 9.999')).not.toBeInTheDocument();
});
});

View File

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, CpfInput, PhoneInput } from '../../components/ui';
import { PROFESSIONAL_ROLE_OPTIONS } from '../../constants/professional-roles';
import { isValidCpf } from '../../components/ui/CpfInput';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createProfessional } from '../../services/professionals.service';
const createProfessionalSchema = z.object({
name: z.string().min(1, 'Nome é obrigatório'),
document: z
.string()
.optional()
.refine((val) => !val || isValidCpf(val), { message: 'CPF inválido' }),
email: z.string().email('E-mail inválido').optional().or(z.literal('')),
phone: z.string().optional(),
role: z.string().optional(),
});
type CreateProfessionalFormData = z.infer<typeof createProfessionalSchema>;
export function ProfessionalCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateProfessionalFormData>({
resolver: zodResolver(createProfessionalSchema),
});
async function onSubmit(data: CreateProfessionalFormData) {
setApiError(null);
try {
await createProfessional({
name: data.name,
document: data.document || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
role: data.role || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['professionals'] });
showToast('Profissional criado com sucesso', 'success');
navigate('/profissionais');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar profissional. Tente novamente.');
} else {
setApiError('Erro ao criar profissional. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Profissional" breadcrumbs={breadcrumbs} />
<FormCard title="Novo Profissional">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do profissional"
error={errors.name?.message}
{...register('name')}
/>
<CpfInput label="Documento" error={errors.document?.message} {...register('document')} />
<Input
label="E-mail"
type="email"
placeholder="profissional@email.com"
error={errors.email?.message}
{...register('email')}
/>
<PhoneInput label="Telefone" error={errors.phone?.message} {...register('phone')} />
<Select
label="Cargo"
placeholder="Selecione um cargo"
options={PROFESSIONAL_ROLE_OPTIONS}
error={errors.role?.message}
{...register('role')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/profissionais')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,43 @@
import { Drawer, DetailField, Badge } from '../../components/ui';
import type { ProfessionalListItem } from '../../types/professional.types';
interface ProfessionalDetailDrawerProps {
open: boolean;
professional: ProfessionalListItem | null;
onClose: () => void;
}
function formatDate(dateStr: string): string {
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function ProfessionalDetailDrawer({
open,
professional,
onClose,
}: ProfessionalDetailDrawerProps) {
return (
<Drawer open={open} title="Detalhes do Profissional" onClose={onClose}>
{professional && (
<div className="flex flex-col gap-5">
<DetailField label="Nome" value={professional.name} />
<DetailField label="Documento" value={professional.document} />
<DetailField label="E-mail" value={professional.email} />
<DetailField label="Telefone" value={professional.phone} />
<DetailField label="Cargo" value={professional.role} />
<DetailField
label="Status"
value={
<Badge variant={professional.isActive ? 'success' : 'danger'}>
{professional.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
<DetailField label="Criado em" value={formatDate(professional.createdAt)} />
<DetailField label="Atualizado em" value={formatDate(professional.updatedAt)} />
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, CpfInput, PhoneInput } from '../../components/ui';
import { PROFESSIONAL_ROLE_OPTIONS } from '../../constants/professional-roles';
import { isValidCpf } from '../../components/ui/CpfInput';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useProfessional } from '../../hooks/useProfessionals';
import { updateProfessional } from '../../services/professionals.service';
const editProfessionalSchema = z.object({
name: z.string().min(1, 'Nome é obrigatório'),
document: z
.string()
.optional()
.refine((val) => !val || isValidCpf(val), { message: 'CPF inválido' }),
email: z.string().email('E-mail inválido').optional().or(z.literal('')),
phone: z.string().optional(),
role: z.string().optional(),
isActive: z.boolean(),
});
type EditProfessionalFormData = z.infer<typeof editProfessionalSchema>;
export function ProfessionalEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
data: professional,
isLoading: isLoadingProfessional,
isError,
} = useProfessional(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':name': professional?.name ?? '' });
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<EditProfessionalFormData>({
resolver: zodResolver(editProfessionalSchema),
});
const isActive = watch('isActive');
useEffect(() => {
if (professional) {
reset({
name: professional.name,
document: professional.document ?? '',
email: professional.email ?? '',
phone: professional.phone ?? '',
role: professional.role ?? '',
isActive: professional.isActive,
});
}
}, [professional, reset]);
async function onSubmit(data: EditProfessionalFormData) {
if (!id) return;
setApiError(null);
try {
await updateProfessional(id, {
name: data.name,
document: data.document || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
role: data.role || undefined,
isActive: data.isActive,
});
await queryClient.invalidateQueries({ queryKey: ['professionals'] });
showToast('Profissional atualizado com sucesso', 'success');
navigate('/profissionais');
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 404) {
setApiError('Profissional não encontrado');
} else {
setApiError(
err.response?.data?.message ?? 'Erro ao atualizar profissional. Tente novamente.',
);
}
} else {
setApiError('Erro ao atualizar profissional. Tente novamente.');
}
}
}
if (isLoadingProfessional) {
return (
<PageContainer>
<PageHeader title="Editar Profissional" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !professional) {
return (
<PageContainer>
<PageHeader title="Editar Profissional" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Profissional não encontrado</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Profissional" breadcrumbs={breadcrumbs} />
<FormCard title="Editar Profissional">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do profissional"
error={errors.name?.message}
{...register('name')}
/>
<CpfInput label="Documento" error={errors.document?.message} {...register('document')} />
<Input
label="E-mail"
type="email"
placeholder="profissional@email.com"
error={errors.email?.message}
{...register('email')}
/>
<PhoneInput label="Telefone" error={errors.phone?.message} {...register('phone')} />
<Select
label="Cargo"
placeholder="Selecione um cargo"
options={PROFESSIONAL_ROLE_OPTIONS}
error={errors.role?.message}
{...register('role')}
/>
<div className="flex items-center justify-between rounded border px-4 py-3">
<span className="text-body text-primary">Status</span>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={isActive ?? true}
onChange={(e) => setValue('isActive', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-danger/40 transition-colors peer-checked:bg-success peer-focus:ring-2 peer-focus:ring-isis-blue after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full" />
<span className="ml-2 text-small text-text-secondary">
{isActive ? 'Ativo' : 'Inativo'}
</span>
</label>
</div>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/profissionais')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,248 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../components/ui';
import { useProfessionals } from '../../hooks/useProfessionals';
import { useToast } from '../../components/ui/Toast';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { ProfessionalDetailDrawer } from './ProfessionalDetailDrawer';
import { updateProfessional } from '../../services/professionals.service';
import type { ProfessionalsFilters, ProfessionalListItem } from '../../types/professional.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
export function ProfessionalsPage() {
const breadcrumbs = useBreadcrumbs();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [professionalToToggle, setProfessionalToToggle] = useState<ProfessionalListItem | null>(
null,
);
const [professionalToView, setProfessionalToView] = useState<ProfessionalListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (professional: ProfessionalListItem) =>
updateProfessional(professional.id, { isActive: !professional.isActive }),
onSuccess: (_data, professional) => {
queryClient.invalidateQueries({ queryKey: ['professionals'] });
showToast(
professional.isActive
? 'Profissional inativado com sucesso'
: 'Profissional ativado com sucesso',
'success',
);
setProfessionalToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do profissional', 'error');
setProfessionalToToggle(null);
},
});
const filters: ProfessionalsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useProfessionals(filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (p: ProfessionalListItem) => <span className="text-primary">{p.name}</span>,
},
{
key: 'document',
header: 'Documento',
render: (p: ProfessionalListItem) => (
<span className="text-text-secondary">{p.document ?? '—'}</span>
),
},
{
key: 'email',
header: 'E-mail',
render: (p: ProfessionalListItem) => (
<span className="text-text-secondary">{p.email ?? '—'}</span>
),
},
{
key: 'phone',
header: 'Telefone',
render: (p: ProfessionalListItem) => (
<span className="text-text-secondary">{p.phone ?? '—'}</span>
),
},
{
key: 'role',
header: 'Cargo',
render: (p: ProfessionalListItem) => (
<span className="text-text-secondary">{p.role ?? '—'}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (p: ProfessionalListItem) => (
<Badge variant={p.isActive ? 'success' : 'danger'}>
{p.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (p: ProfessionalListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setProfessionalToView(p)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/profissionais/${p.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
<Tooltip content={p.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setProfessionalToToggle(p)}
className={
p.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={p.isActive ? 'Inativar' : 'Ativar'}
>
{p.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
</div>
),
},
],
[navigate],
);
return (
<PageContainer>
<PageHeader
title="Profissionais"
breadcrumbs={breadcrumbs}
actions={
<Button onClick={() => navigate('/profissionais/novo')} icon={<Plus size={16} />}>
Novo Profissional
</Button>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
</div>
<DataTable<ProfessionalListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum profissional encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="profissional"
itemLabelPlural="profissionais"
onPageChange={setPage}
/>
<ProfessionalDetailDrawer
open={!!professionalToView}
professional={professionalToView}
onClose={() => setProfessionalToView(null)}
/>
<ConfirmDialog
open={!!professionalToToggle}
title={professionalToToggle?.isActive ? 'Inativar profissional' : 'Ativar profissional'}
message={
professionalToToggle?.isActive
? `Tem certeza que deseja inativar o profissional "${professionalToToggle?.name}"?`
: `Tem certeza que deseja ativar o profissional "${professionalToToggle?.name}"?`
}
confirmLabel={professionalToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={professionalToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (professionalToToggle) toggleStatusMutation.mutate(professionalToToggle);
}}
onCancel={() => setProfessionalToToggle(null)}
/>
</PageContainer>
);
}

View File

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createProject } from '../../services/projects.service';
import { useContracts } from '../../hooks/useContracts';
const createProjectSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
contractId: z.string().min(1, 'Contrato é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type CreateProjectFormData = z.infer<typeof createProjectSchema>;
export function ProjectCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: contractsData } = useContracts({ isActive: 'true', page: 1, limit: 100 });
const contractOptions = (contractsData?.data ?? []).map((c) => ({
value: c.id,
label: c.name,
}));
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateProjectFormData>({
resolver: zodResolver(createProjectSchema),
});
async function onSubmit(data: CreateProjectFormData) {
setApiError(null);
try {
await createProject({
name: data.name,
contractId: data.contractId,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Projeto criado com sucesso', 'success');
navigate('/projetos');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar projeto. Tente novamente.');
} else {
setApiError('Erro ao criar projeto. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Novo Projeto" breadcrumbs={breadcrumbs} />
<FormCard title="Novo Projeto">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do projeto"
error={errors.name?.message}
{...register('name')}
/>
<Select
label="Contrato"
options={contractOptions}
placeholder="Selecione um contrato"
error={errors.contractId?.message}
{...register('contractId')}
/>
<Input
label="Código"
placeholder="Código do projeto"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do projeto"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/projetos')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,42 @@
import { Drawer, DetailField, Badge } from '../../components/ui';
import type { ProjectListItem } from '../../types/project.types';
interface ProjectDetailDrawerProps {
open: boolean;
project: ProjectListItem | null;
onClose: () => void;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function ProjectDetailDrawer({ open, project, onClose }: ProjectDetailDrawerProps) {
return (
<Drawer open={open} title="Detalhes do Projeto" onClose={onClose}>
{project && (
<div className="flex flex-col gap-5">
<DetailField label="Nome" value={project.name} />
<DetailField label="Código" value={project.code} />
<DetailField label="Contrato" value={project.contract.name} />
<DetailField label="Cliente" value={project.contract.client.name} />
<DetailField label="Descrição" value={project.description} />
<DetailField label="Data de Início" value={formatDate(project.startDate)} />
<DetailField label="Data de Término" value={formatDate(project.endDate)} />
<DetailField
label="Status"
value={
<Badge variant={project.isActive ? 'success' : 'danger'}>
{project.isActive ? 'Ativo' : 'Inativo'}
</Badge>
}
/>
<DetailField label="Criado em" value={formatDate(project.createdAt)} />
<DetailField label="Atualizado em" value={formatDate(project.updatedAt)} />
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, Select, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useProject } from '../../hooks/useProjects';
import { useContracts } from '../../hooks/useContracts';
import { updateProject } from '../../services/projects.service';
const editProjectSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
contractId: z.string().min(1, 'Contrato é obrigatório'),
code: z.string().optional(),
description: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
isActive: z.boolean(),
})
.refine(
(data) => {
if (data.startDate && data.endDate) {
return new Date(data.endDate) >= new Date(data.startDate);
}
return true;
},
{
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
},
);
type EditProjectFormData = z.infer<typeof editProjectSchema>;
function toDateInputValue(dateStr: string | null): string {
if (!dateStr) return '';
return dateStr.slice(0, 10);
}
export function ProjectEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: project, isLoading: isLoadingProject, isError } = useProject(id ?? '');
const { data: contractsData } = useContracts({ isActive: 'true', page: 1, limit: 100 });
const breadcrumbs = useBreadcrumbs({ ':name': project?.name ?? '' });
const contractOptions = (contractsData?.data ?? []).map((c) => ({
value: c.id,
label: c.name,
}));
const {
register,
handleSubmit,
reset,
watch,
setValue,
control,
formState: { errors, isSubmitting },
} = useForm<EditProjectFormData>({
resolver: zodResolver(editProjectSchema),
});
const isActive = watch('isActive');
useEffect(() => {
if (project) {
reset({
name: project.name,
contractId: project.contract.id,
code: project.code ?? '',
description: project.description ?? '',
startDate: toDateInputValue(project.startDate),
endDate: toDateInputValue(project.endDate),
isActive: project.isActive,
});
}
}, [project, reset]);
async function onSubmit(data: EditProjectFormData) {
if (!id) return;
setApiError(null);
try {
await updateProject(id, {
name: data.name,
contractId: data.contractId,
code: data.code || undefined,
description: data.description || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
isActive: data.isActive,
});
await queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Projeto atualizado com sucesso', 'success');
navigate('/projetos');
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 404) {
setApiError('Projeto não encontrado');
} else {
setApiError(err.response?.data?.message ?? 'Erro ao atualizar projeto. Tente novamente.');
}
} else {
setApiError('Erro ao atualizar projeto. Tente novamente.');
}
}
}
if (isLoadingProject) {
return (
<PageContainer>
<PageHeader title="Editar Projeto" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !project) {
return (
<PageContainer>
<PageHeader title="Editar Projeto" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Projeto não encontrado</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Projeto" breadcrumbs={breadcrumbs} />
<FormCard title="Editar Projeto">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome do projeto"
error={errors.name?.message}
{...register('name')}
/>
<Select
label="Contrato"
options={contractOptions}
placeholder="Selecione um contrato"
error={errors.contractId?.message}
{...register('contractId')}
/>
<Input
label="Código"
placeholder="Código do projeto"
error={errors.code?.message}
{...register('code')}
/>
<Input
label="Descrição"
placeholder="Descrição do projeto"
error={errors.description?.message}
{...register('description')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
<div className="flex items-center justify-between rounded border px-4 py-3">
<span className="text-body text-primary">Status</span>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={isActive ?? true}
onChange={(e) => setValue('isActive', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-danger/40 transition-colors peer-checked:bg-success peer-focus:ring-2 peer-focus:ring-isis-blue after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full" />
<span className="ml-2 text-small text-text-secondary">
{isActive ? 'Ativo' : 'Inativo'}
</span>
</label>
</div>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/projetos')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,250 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye, UserX, UserCheck } from 'lucide-react';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../components/ui';
import { useProjects } from '../../hooks/useProjects';
import { useToast } from '../../components/ui/Toast';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { ProjectDetailDrawer } from './ProjectDetailDrawer';
import { updateProject } from '../../services/projects.service';
import type { ProjectsFilters, ProjectListItem } from '../../types/project.types';
const STATUS_OPTIONS = [
{ value: 'true', label: 'Ativo' },
{ value: 'false', label: 'Inativo' },
];
const ITEMS_PER_PAGE = 20;
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function ProjectsPage() {
const breadcrumbs = useBreadcrumbs();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const [projectToToggle, setProjectToToggle] = useState<ProjectListItem | null>(null);
const [projectToView, setProjectToView] = useState<ProjectListItem | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const queryClient = useQueryClient();
const toggleStatusMutation = useMutation({
mutationFn: (project: ProjectListItem) =>
updateProject(project.id, { isActive: !project.isActive }),
onSuccess: (_data, project) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast(
project.isActive ? 'Projeto inativado com sucesso' : 'Projeto ativado com sucesso',
'success',
);
setProjectToToggle(null);
},
onError: () => {
showToast('Erro ao alterar status do projeto', 'error');
setProjectToToggle(null);
},
});
const filters: ProjectsFilters = useMemo(
() => ({
search: searchValue || undefined,
isActive: statusFilter,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useProjects(filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (p: ProjectListItem) => <span className="text-primary">{p.name}</span>,
},
{
key: 'code',
header: 'Código',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{p.code ?? '—'}</span>
),
},
{
key: 'contract',
header: 'Contrato',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{p.contract.name}</span>
),
},
{
key: 'startDate',
header: 'Data Início',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{formatDate(p.startDate)}</span>
),
},
{
key: 'endDate',
header: 'Data Fim',
render: (p: ProjectListItem) => (
<span className="text-text-secondary">{formatDate(p.endDate)}</span>
),
},
{
key: 'isActive',
header: 'Status',
render: (p: ProjectListItem) => (
<Badge variant={p.isActive ? 'success' : 'danger'}>
{p.isActive ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: 'Ações',
render: (p: ProjectListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => setProjectToView(p)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/projetos/${p.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
<Tooltip content={p.isActive ? 'Inativar' : 'Ativar'}>
<Button
variant="ghost"
size="icon"
onClick={() => setProjectToToggle(p)}
className={
p.isActive
? 'text-danger hover:text-danger/80'
: 'text-success hover:text-success/80'
}
aria-label={p.isActive ? 'Inativar' : 'Ativar'}
>
{p.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
</Button>
</Tooltip>
</div>
),
},
],
[navigate],
);
return (
<PageContainer>
<PageHeader
title="Projetos"
breadcrumbs={breadcrumbs}
actions={
<Button onClick={() => navigate('/projetos/novo')} icon={<Plus size={16} />}>
Novo Projeto
</Button>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
</div>
<DataTable<ProjectListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhum projeto encontrado"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="projeto"
itemLabelPlural="projetos"
onPageChange={setPage}
/>
<ProjectDetailDrawer
open={!!projectToView}
project={projectToView}
onClose={() => setProjectToView(null)}
/>
<ConfirmDialog
open={!!projectToToggle}
title={projectToToggle?.isActive ? 'Inativar projeto' : 'Ativar projeto'}
message={
projectToToggle?.isActive
? `Tem certeza que deseja inativar o projeto "${projectToToggle?.name}"?`
: `Tem certeza que deseja ativar o projeto "${projectToToggle?.name}"?`
}
confirmLabel={projectToToggle?.isActive ? 'Inativar' : 'Ativar'}
variant={projectToToggle?.isActive ? 'danger' : 'default'}
loading={toggleStatusMutation.isPending}
onConfirm={() => {
if (projectToToggle) toggleStatusMutation.mutate(projectToToggle);
}}
onCancel={() => setProjectToToggle(null)}
/>
</PageContainer>
);
}

View File

View File

@@ -0,0 +1,12 @@
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
export function SettingsPage() {
const breadcrumbs = useBreadcrumbs();
return (
<PageContainer>
<PageHeader title="Configurações" breadcrumbs={breadcrumbs} />
</PageContainer>
);
}

View File

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { createSprint } from '../../services/sprints.service';
const createSprintSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
code: z.string().optional(),
startDate: z.string().min(1, 'Data de início é obrigatória'),
endDate: z.string().min(1, 'Data de término é obrigatória'),
goal: z.string().optional(),
})
.refine((data) => new Date(data.endDate) >= new Date(data.startDate), {
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
});
type CreateSprintFormData = z.infer<typeof createSprintSchema>;
export function SprintCreatePage() {
const breadcrumbs = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateSprintFormData>({
resolver: zodResolver(createSprintSchema),
});
async function onSubmit(data: CreateSprintFormData) {
setApiError(null);
try {
await createSprint({
name: data.name,
code: data.code || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
goal: data.goal || undefined,
});
await queryClient.invalidateQueries({ queryKey: ['sprints'] });
showToast('Sprint criada com sucesso', 'success');
navigate('/sprints');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao criar sprint. Tente novamente.');
} else {
setApiError('Erro ao criar sprint. Tente novamente.');
}
}
}
return (
<PageContainer>
<PageHeader title="Nova Sprint" breadcrumbs={breadcrumbs} />
<FormCard title="Nova Sprint">
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome da sprint"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Código"
placeholder="Código da sprint"
error={errors.code?.message}
{...register('code')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
<Input
label="Objetivo"
placeholder="Objetivo da sprint"
error={errors.goal?.message}
{...register('goal')}
/>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/sprints')}>
Cancelar
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,426 @@
import { useState, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Pencil,
Play,
Square,
XCircle,
Plus,
Trash2,
ArrowRight,
Minus,
RefreshCw,
Calendar,
} from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { Badge, Button, DataTable, Tooltip } from '../../components/ui';
import { ConfirmDialog } from '../../components/ui/ConfirmDialog';
import { StatusBadge } from '../../components/shared/StatusBadge';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useReadOnly } from '../../hooks/useReadOnly';
import { useToast } from '../../components/ui/Toast';
import { useSprint } from '../../hooks/useSprints';
import {
startSprint,
cancelSprint,
finishSprint,
removeServiceOrderFromSprint,
} from '../../services/sprints.service';
import { SPRINT_STATUS_LABELS, SPRINT_STATUS_VARIANTS } from '../../constants/sprint';
import { AddServiceOrderModal } from './components/AddServiceOrderModal';
import { FinishSprintModal } from './components/FinishSprintModal';
import type { SprintDeliverable, SprintHistoryItem } from '../../types/sprint.types';
import type { DeliverableStatus } from '../../types/deliverable.types';
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('pt-BR');
}
const HISTORY_ICONS: Record<string, typeof Plus> = {
ENTREGAVEL_ADICIONADO: Plus,
ENTREGAVEL_REMOVIDO: Minus,
ENTREGAVEL_MOVIDO: ArrowRight,
STATUS_CHANGE: RefreshCw,
};
export function SprintDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { isReadOnly } = useReadOnly();
const { data: sprint, isLoading, isError } = useSprint(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':name': sprint?.name ?? '' });
const [confirmAction, setConfirmAction] = useState<'start' | 'cancel' | 'finish' | null>(null);
const [osToRemove, setOsToRemove] = useState<SprintDeliverable | null>(null);
const [showAddOsModal, setShowAddOsModal] = useState(false);
const [showFinishModal, setShowFinishModal] = useState(false);
const isMutable = sprint?.status !== 'FINALIZADA' && sprint?.status !== 'CANCELADA';
const startMutation = useMutation({
mutationFn: () => startSprint(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
queryClient.invalidateQueries({ queryKey: ['sprints'] });
showToast('Sprint iniciada com sucesso', 'success');
setConfirmAction(null);
},
onError: () => {
showToast('Erro ao iniciar sprint', 'error');
setConfirmAction(null);
},
});
const cancelMutation = useMutation({
mutationFn: () => cancelSprint(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
queryClient.invalidateQueries({ queryKey: ['sprints'] });
showToast('Sprint cancelada com sucesso', 'success');
setConfirmAction(null);
},
onError: () => {
showToast('Erro ao cancelar sprint', 'error');
setConfirmAction(null);
},
});
const finishMutation = useMutation({
mutationFn: () => finishSprint(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
queryClient.invalidateQueries({ queryKey: ['sprints'] });
showToast('Sprint finalizada com sucesso', 'success');
setConfirmAction(null);
},
onError: () => {
showToast('Erro ao finalizar sprint', 'error');
setConfirmAction(null);
},
});
const removeOsMutation = useMutation({
mutationFn: (serviceOrderId: string) => removeServiceOrderFromSprint(id!, serviceOrderId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
showToast('Entregável removido da sprint', 'success');
setOsToRemove(null);
},
onError: () => {
showToast('Erro ao remover entregável da sprint', 'error');
setOsToRemove(null);
},
});
const pendingOrders = useMemo(() => {
if (!sprint) return [];
const terminalStatuses = ['ENCERRADA', 'PAGA', 'CANCELADA'];
return sprint.deliverables.filter((so) => !terminalStatuses.includes(so.status));
}, [sprint]);
const osColumns = useMemo(
() => [
{
key: 'code',
header: 'Código',
render: (so: SprintDeliverable) => (
<button
type="button"
className="text-primary hover:underline text-left"
onClick={() => navigate(`/entregaveis/${so.id}`)}
>
{so.code}
</button>
),
},
{
key: 'title',
header: 'Título',
render: (so: SprintDeliverable) => <span className="text-text-primary">{so.title}</span>,
},
{
key: 'status',
header: 'Status',
render: (so: SprintDeliverable) => <StatusBadge status={so.status as DeliverableStatus} />,
},
{
key: 'client',
header: 'Cliente',
render: (so: SprintDeliverable) => (
<span className="text-text-secondary">{so.client.name}</span>
),
},
{
key: 'project',
header: 'Projeto',
render: (so: SprintDeliverable) => (
<span className="text-text-secondary">{so.project.name}</span>
),
},
...(!isReadOnly && isMutable
? [
{
key: 'actions',
header: 'Ações',
render: (so: SprintDeliverable) => (
<Tooltip content="Remover da sprint">
<Button
variant="ghost"
size="icon"
className="text-danger hover:text-danger/80"
onClick={() => setOsToRemove(so)}
aria-label="Remover"
>
<Trash2 size={14} />
</Button>
</Tooltip>
),
},
]
: []),
],
[navigate, isReadOnly, isMutable],
);
function handleFinishClick() {
if (pendingOrders.length > 0) {
setShowFinishModal(true);
} else {
setConfirmAction('finish');
}
}
if (isLoading) {
return (
<PageContainer>
<PageHeader title="Sprint" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !sprint) {
return (
<PageContainer>
<PageHeader title="Sprint" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Sprint não encontrada</div>
</PageContainer>
);
}
const actions = !isReadOnly ? (
<div className="flex items-center gap-2">
{sprint.status === 'RASCUNHO' && (
<>
<Button
variant="secondary"
icon={<Pencil size={14} />}
onClick={() => navigate(`/sprints/${id}/editar`)}
>
Editar
</Button>
<Button icon={<Play size={14} />} onClick={() => setConfirmAction('start')}>
Iniciar Sprint
</Button>
<Button
variant="danger"
icon={<XCircle size={14} />}
onClick={() => setConfirmAction('cancel')}
>
Cancelar
</Button>
</>
)}
{sprint.status === 'EM_EXECUCAO' && (
<Button icon={<Square size={14} />} onClick={handleFinishClick}>
Finalizar Sprint
</Button>
)}
</div>
) : undefined;
return (
<PageContainer>
<PageHeader title={sprint.name} breadcrumbs={breadcrumbs} actions={actions} />
{/* Info badges */}
<div className="mb-6 flex flex-wrap items-center gap-3">
{sprint.code && <span className="text-text-secondary text-small">{sprint.code}</span>}
<Badge variant={SPRINT_STATUS_VARIANTS[sprint.status] as never}>
{SPRINT_STATUS_LABELS[sprint.status]}
</Badge>
</div>
{/* Goal */}
{sprint.goal && <p className="mb-6 text-text-secondary">{sprint.goal}</p>}
{/* Info cards */}
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-surface p-4">
<div className="flex items-center gap-2 text-text-muted text-small mb-1">
<Calendar size={14} />
Data de Início
</div>
<div className="text-text-primary font-medium">{formatDate(sprint.startDate)}</div>
</div>
<div className="rounded-lg border bg-surface p-4">
<div className="flex items-center gap-2 text-text-muted text-small mb-1">
<Calendar size={14} />
Data de Término
</div>
<div className="text-text-primary font-medium">{formatDate(sprint.endDate)}</div>
</div>
<div className="rounded-lg border bg-surface p-4">
<div className="text-text-muted text-small mb-1">Total de Entregáveis</div>
<div className="text-text-primary font-medium text-lg">{sprint.totalDeliverables}</div>
</div>
{Object.entries(sprint.statusSummary).length > 0 && (
<div className="rounded-lg border bg-surface p-4">
<div className="text-text-muted text-small mb-2">Entregáveis por status</div>
<div className="flex flex-wrap gap-2">
{Object.entries(sprint.statusSummary).map(([status]) => (
<StatusBadge key={status} status={status as DeliverableStatus} />
))}
</div>
</div>
)}
</div>
{/* Deliverables section */}
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-subtitle text-text-primary">
Entregáveis ({sprint.totalDeliverables})
</h2>
{!isReadOnly && isMutable && (
<Button
variant="secondary"
size="sm"
icon={<Plus size={14} />}
onClick={() => setShowAddOsModal(true)}
>
Adicionar Entregável
</Button>
)}
</div>
<DataTable<SprintDeliverable>
columns={osColumns}
data={sprint.deliverables}
isLoading={false}
emptyMessage="Nenhum entregável vinculado a esta sprint"
rowKey="id"
/>
</div>
{/* History section */}
<div>
<h2 className="text-subtitle text-text-primary mb-4">Histórico</h2>
{sprint.history.length === 0 ? (
<p className="text-text-muted text-small">Nenhum evento registrado</p>
) : (
<div className="space-y-3">
{sprint.history.map((event: SprintHistoryItem) => {
const Icon = HISTORY_ICONS[event.eventType] ?? RefreshCw;
return (
<div
key={event.id}
className="flex items-start gap-3 rounded-lg border bg-surface p-3"
>
<div className="mt-0.5 rounded-full bg-isis-blue/10 p-1.5 text-isis-blue">
<Icon size={14} />
</div>
<div className="flex-1 min-w-0">
<p className="text-text-primary text-small">{event.description}</p>
<p className="text-text-muted text-xs mt-0.5">
{formatDateTime(event.createdAt)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Confirm start */}
<ConfirmDialog
open={confirmAction === 'start'}
title="Iniciar Sprint"
message={`Deseja iniciar a sprint "${sprint.name}"? O status será alterado para "Em Execução".`}
confirmLabel="Iniciar"
loading={startMutation.isPending}
onConfirm={() => startMutation.mutate()}
onCancel={() => setConfirmAction(null)}
/>
{/* Confirm cancel */}
<ConfirmDialog
open={confirmAction === 'cancel'}
title="Cancelar Sprint"
message={`Deseja cancelar a sprint "${sprint.name}"? Esta ação não pode ser desfeita.`}
confirmLabel="Cancelar Sprint"
variant="danger"
loading={cancelMutation.isPending}
onConfirm={() => cancelMutation.mutate()}
onCancel={() => setConfirmAction(null)}
/>
{/* Confirm remove entregável */}
<ConfirmDialog
open={!!osToRemove}
title="Remover Entregável da Sprint"
message={`Deseja remover o entregável "${osToRemove?.code}${osToRemove?.title}" desta sprint?`}
confirmLabel="Remover"
variant="danger"
loading={removeOsMutation.isPending}
onConfirm={() => {
if (osToRemove) removeOsMutation.mutate(osToRemove.id);
}}
onCancel={() => setOsToRemove(null)}
/>
<AddServiceOrderModal
open={showAddOsModal}
onClose={() => setShowAddOsModal(false)}
sprintId={id!}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
}}
/>
<FinishSprintModal
open={showFinishModal}
onClose={() => setShowFinishModal(false)}
sprintId={id!}
pendingOrders={pendingOrders}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['sprint', id] });
queryClient.invalidateQueries({ queryKey: ['sprints'] });
}}
/>
{/* Confirm finish (no pending entregável) */}
<ConfirmDialog
open={confirmAction === 'finish'}
title="Finalizar Sprint"
message={`Deseja finalizar a sprint "${sprint.name}"? O status será alterado para "Finalizada".`}
confirmLabel="Finalizar"
loading={finishMutation.isPending}
onConfirm={() => finishMutation.mutate()}
onCancel={() => setConfirmAction(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { FormCard, PageContainer, PageHeader } from '../../components/layout';
import { Button, Input, DatePickerField } from '../../components/ui';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import { useToast } from '../../components/ui/Toast';
import { useSprint } from '../../hooks/useSprints';
import { updateSprint } from '../../services/sprints.service';
const editSprintSchema = z
.object({
name: z.string().min(1, 'Nome é obrigatório'),
code: z.string().optional(),
startDate: z.string().min(1, 'Data de início é obrigatória'),
endDate: z.string().min(1, 'Data de término é obrigatória'),
goal: z.string().optional(),
isActive: z.boolean(),
})
.refine((data) => new Date(data.endDate) >= new Date(data.startDate), {
message: 'Data de término deve ser igual ou posterior à data de início',
path: ['endDate'],
});
type EditSprintFormData = z.infer<typeof editSprintSchema>;
function toDateInputValue(dateStr: string | null): string {
if (!dateStr) return '';
return dateStr.slice(0, 10);
}
export function SprintEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [apiError, setApiError] = useState<string | null>(null);
const { data: sprint, isLoading: isLoadingSprint, isError } = useSprint(id ?? '');
const breadcrumbs = useBreadcrumbs({ ':name': sprint?.name ?? '' });
const isImmutable = sprint?.status === 'FINALIZADA' || sprint?.status === 'CANCELADA';
const {
register,
handleSubmit,
reset,
watch,
setValue,
control,
formState: { errors, isSubmitting },
} = useForm<EditSprintFormData>({
resolver: zodResolver(editSprintSchema),
});
const isActive = watch('isActive');
useEffect(() => {
if (sprint) {
reset({
name: sprint.name,
code: sprint.code ?? '',
startDate: toDateInputValue(sprint.startDate),
endDate: toDateInputValue(sprint.endDate),
goal: sprint.goal ?? '',
isActive: sprint.isActive,
});
}
}, [sprint, reset]);
async function onSubmit(data: EditSprintFormData) {
if (!id) return;
setApiError(null);
try {
await updateSprint(id, {
name: data.name,
code: data.code || undefined,
startDate: data.startDate || undefined,
endDate: data.endDate || undefined,
goal: data.goal || undefined,
isActive: data.isActive,
});
await queryClient.invalidateQueries({ queryKey: ['sprints'] });
showToast('Sprint atualizada com sucesso', 'success');
navigate('/sprints');
} catch (err) {
if (axios.isAxiosError(err)) {
setApiError(err.response?.data?.message ?? 'Erro ao atualizar sprint. Tente novamente.');
} else {
setApiError('Erro ao atualizar sprint. Tente novamente.');
}
}
}
if (isLoadingSprint) {
return (
<PageContainer>
<PageHeader title="Editar Sprint" breadcrumbs={breadcrumbs} />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
</PageContainer>
);
}
if (isError || !sprint) {
return (
<PageContainer>
<PageHeader title="Editar Sprint" breadcrumbs={breadcrumbs} />
<div className="py-12 text-center text-text-muted">Sprint não encontrada</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader title="Editar Sprint" breadcrumbs={breadcrumbs} />
{isImmutable && (
<div className="mb-4 rounded border border-warning/30 bg-warning/10 px-4 py-3 text-small text-warning">
Esta sprint está finalizada/cancelada e não pode ser editada.
</div>
)}
<FormCard title="Editar Sprint">
<fieldset disabled={isImmutable}>
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)} noValidate className="space-y-5">
<Input
label="Nome"
placeholder="Nome da sprint"
error={errors.name?.message}
{...register('name')}
/>
<Input
label="Código"
placeholder="Código da sprint"
error={errors.code?.message}
{...register('code')}
/>
<DatePickerField name="startDate" control={control} label="Data de Início" />
<DatePickerField name="endDate" control={control} label="Data de Término" />
<Input
label="Objetivo"
placeholder="Objetivo da sprint"
error={errors.goal?.message}
{...register('goal')}
/>
<div className="flex items-center justify-between rounded border px-4 py-3">
<span className="text-body text-primary">Status</span>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={isActive ?? true}
onChange={(e) => setValue('isActive', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-danger/40 transition-colors peer-checked:bg-success peer-focus:ring-2 peer-focus:ring-isis-blue after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full" />
<span className="ml-2 text-small text-text-secondary">
{isActive ? 'Ativo' : 'Inativo'}
</span>
</label>
</div>
{apiError && <p className="text-small text-danger">{apiError}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={() => navigate('/sprints')}>
Cancelar
</Button>
{!isImmutable && (
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Salvando...' : 'Salvar'}
</Button>
)}
</div>
</form>
</fieldset>
</FormCard>
</PageContainer>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Eye } from 'lucide-react';
import { PageContainer, PageHeader } from '../../components/layout';
import { useBreadcrumbs } from '../../hooks/useBreadcrumbs';
import {
Button,
SearchInput,
Select,
Badge,
DataTable,
Pagination,
Tooltip,
} from '../../components/ui';
import { useSprints } from '../../hooks/useSprints';
import { useReadOnly } from '../../hooks/useReadOnly';
import {
SPRINT_STATUS_LABELS,
SPRINT_STATUS_OPTIONS,
SPRINT_STATUS_VARIANTS,
} from '../../constants/sprint';
import type { SprintsFilters, SprintListItem } from '../../types/sprint.types';
const ITEMS_PER_PAGE = 20;
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const [y, m, d] = dateStr.slice(0, 10).split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString('pt-BR');
}
export function SprintsPage() {
const breadcrumbs = useBreadcrumbs();
const { isReadOnly } = useReadOnly();
const [searchValue, setSearchValue] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1);
const navigate = useNavigate();
const filters: SprintsFilters = useMemo(
() => ({
search: searchValue || undefined,
status: statusFilter || undefined,
page,
limit: ITEMS_PER_PAGE,
}),
[searchValue, statusFilter, page],
);
const { data, isLoading } = useSprints(filters);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
function handleSearchChange(value: string) {
setSearchValue(value);
setPage(1);
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatusFilter(e.target.value);
setPage(1);
}
const columns = useMemo(
() => [
{
key: 'name',
header: 'Nome',
render: (s: SprintListItem) => (
<button
type="button"
className="text-primary hover:underline text-left"
onClick={() => navigate(`/sprints/${s.id}`)}
>
{s.name}
</button>
),
},
{
key: 'code',
header: 'Código',
render: (s: SprintListItem) => <span className="text-text-secondary">{s.code ?? '—'}</span>,
},
{
key: 'status',
header: 'Status',
render: (s: SprintListItem) => (
<Badge variant={SPRINT_STATUS_VARIANTS[s.status] as never}>
{SPRINT_STATUS_LABELS[s.status]}
</Badge>
),
},
{
key: 'startDate',
header: 'Data Início',
render: (s: SprintListItem) => (
<span className="text-text-secondary">{formatDate(s.startDate)}</span>
),
},
{
key: 'endDate',
header: 'Data Fim',
render: (s: SprintListItem) => (
<span className="text-text-secondary">{formatDate(s.endDate)}</span>
),
},
{
key: 'actions',
header: 'Ações',
render: (s: SprintListItem) => (
<div className="flex items-center gap-3">
<Tooltip content="Visualizar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/sprints/${s.id}`)}
aria-label="Visualizar"
>
<Eye size={14} />
</Button>
</Tooltip>
{!isReadOnly && (
<Tooltip content="Editar">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/sprints/${s.id}/editar`)}
aria-label="Editar"
>
<Pencil size={14} />
</Button>
</Tooltip>
)}
</div>
),
},
],
[navigate, isReadOnly],
);
return (
<PageContainer>
<PageHeader
title="Sprints"
breadcrumbs={breadcrumbs}
actions={
!isReadOnly ? (
<Button onClick={() => navigate('/sprints/novo')} icon={<Plus size={16} />}>
Nova Sprint
</Button>
) : undefined
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<SearchInput
value={searchValue}
onChange={handleSearchChange}
placeholder="Buscar por nome..."
/>
</div>
<Select
options={SPRINT_STATUS_OPTIONS}
placeholder="Todos os status"
value={statusFilter}
onChange={handleStatusChange}
/>
</div>
<DataTable<SprintListItem>
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
emptyMessage="Nenhuma sprint encontrada"
rowKey="id"
/>
<Pagination
page={page}
totalPages={totalPages}
totalItems={data?.total ?? 0}
itemLabel="sprint"
itemLabelPlural="sprints"
onPageChange={setPage}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,145 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Drawer } from '../../../components/ui/Drawer';
import { Button, SearchInput } from '../../../components/ui';
import { StatusBadge } from '../../../components/shared/StatusBadge';
import { useToast } from '../../../components/ui/Toast';
import { getDeliverables } from '../../../services/deliverables.service';
import { addServiceOrdersToSprint } from '../../../services/sprints.service';
import type { DeliverableListItem } from '../../../types/deliverable.types';
interface AddServiceOrderModalProps {
open: boolean;
onClose: () => void;
sprintId: string;
onSuccess: () => void;
}
export function AddServiceOrderModal({
open,
onClose,
sprintId,
onSuccess,
}: AddServiceOrderModalProps) {
const { showToast } = useToast();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const { data, isLoading } = useQuery({
queryKey: ['deliverables-available', search],
queryFn: () =>
getDeliverables({
search: search || undefined,
page: 1,
limit: 50,
}),
enabled: open,
});
// Filter out deliverables that are already in an active sprint (EM_EXECUCAO)
const availableOrders = useMemo(() => {
if (!data?.data) return [];
return data.data.filter((so) => !so.sprint || so.sprint.id === sprintId);
}, [data, sprintId]);
const addMutation = useMutation({
mutationFn: () => addServiceOrdersToSprint(sprintId, Array.from(selectedIds)),
onSuccess: () => {
showToast('Entregável(is) adicionado(s) com sucesso', 'success');
setSelectedIds(new Set());
setSearch('');
onSuccess();
onClose();
},
onError: () => {
showToast('Erro ao adicionar entregável(is) à sprint', 'error');
},
});
function toggleSelection(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function handleClose() {
setSelectedIds(new Set());
setSearch('');
onClose();
}
return (
<Drawer open={open} title="Adicionar Entregáveis" onClose={handleClose} width="max-w-2xl">
<div className="flex flex-col gap-4 p-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por código ou título..."
/>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-isis-blue border-t-transparent" />
</div>
) : availableOrders.length === 0 ? (
<p className="py-8 text-center text-text-muted text-small">
Nenhum entregável disponível encontrado
</p>
) : (
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{availableOrders.map((so: DeliverableListItem) => {
const isSelected = selectedIds.has(so.id);
return (
<label
key={so.id}
className={`flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
isSelected ? 'border-isis-blue bg-isis-blue/5' : 'border-border hover:bg-hover'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(so.id)}
className="h-4 w-4 rounded border-border text-isis-blue focus:ring-isis-blue"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary text-small">{so.code}</span>
<StatusBadge status={so.status} size="sm" />
</div>
<p className="text-text-secondary text-xs truncate">{so.title}</p>
<p className="text-text-muted text-xs">{so.client.name}</p>
</div>
</label>
);
})}
</div>
)}
<div className="flex items-center justify-between border-t pt-4">
<span className="text-text-secondary text-small">
{selectedIds.size} entregável(is) selecionado(s)
</span>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleClose}>
Cancelar
</Button>
<Button
onClick={() => addMutation.mutate()}
disabled={selectedIds.size === 0}
loading={addMutation.isPending}
>
Adicionar
</Button>
</div>
</div>
</div>
</Drawer>
);
}

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