Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
153
src/pages/__tests__/detail-form-pages-breadcrumbs.test.tsx
Normal file
153
src/pages/__tests__/detail-form-pages-breadcrumbs.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
64
src/pages/__tests__/listing-pages-breadcrumbs.test.tsx
Normal file
64
src/pages/__tests__/listing-pages-breadcrumbs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
65
src/pages/admin/api-keys/ApiKeyCreatedDialog.tsx
Normal file
65
src/pages/admin/api-keys/ApiKeyCreatedDialog.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
98
src/pages/admin/api-keys/ApiKeyTable.tsx
Normal file
98
src/pages/admin/api-keys/ApiKeyTable.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
src/pages/admin/api-keys/ApiKeysPage.tsx
Normal file
111
src/pages/admin/api-keys/ApiKeysPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/pages/admin/api-keys/CreateApiKeyModal.tsx
Normal file
189
src/pages/admin/api-keys/CreateApiKeyModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
1
src/pages/admin/api-keys/index.ts
Normal file
1
src/pages/admin/api-keys/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ApiKeysPage } from './ApiKeysPage';
|
||||
160
src/pages/auth/ChangePasswordPage.tsx
Normal file
160
src/pages/auth/ChangePasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/pages/auth/ForgotPasswordPage.tsx
Normal file
110
src/pages/auth/ForgotPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/pages/auth/LoginPage.tsx
Normal file
106
src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/pages/auth/ResetPasswordPage.tsx
Normal file
154
src/pages/auth/ResetPasswordPage.tsx
Normal 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ê já 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>
|
||||
);
|
||||
}
|
||||
145
src/pages/auth/__tests__/ChangePasswordPage.test.tsx
Normal file
145
src/pages/auth/__tests__/ChangePasswordPage.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
Normal file
89
src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
171
src/pages/auth/__tests__/LoginPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/pages/auth/__tests__/ResetPasswordPage.test.tsx
Normal file
103
src/pages/auth/__tests__/ResetPasswordPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
0
src/pages/clients/.gitkeep
Normal file
0
src/pages/clients/.gitkeep
Normal file
128
src/pages/clients/ClientContractCreatePage.tsx
Normal file
128
src/pages/clients/ClientContractCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/pages/clients/ClientContractItemCreatePage.tsx
Normal file
273
src/pages/clients/ClientContractItemCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
src/pages/clients/ClientContractItemEditPage.tsx
Normal file
328
src/pages/clients/ClientContractItemEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/pages/clients/ClientCreatePage.tsx
Normal file
124
src/pages/clients/ClientCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/pages/clients/ClientDetailDrawer.tsx
Normal file
40
src/pages/clients/ClientDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/pages/clients/ClientDetailPage.tsx
Normal file
161
src/pages/clients/ClientDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/pages/clients/ClientEditPage.tsx
Normal file
191
src/pages/clients/ClientEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/pages/clients/ClientProjectCreatePage.tsx
Normal file
145
src/pages/clients/ClientProjectCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/pages/clients/ClientsPage.tsx
Normal file
246
src/pages/clients/ClientsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
525
src/pages/clients/components/ClientAllocationTemplatesTab.tsx
Normal file
525
src/pages/clients/components/ClientAllocationTemplatesTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
361
src/pages/clients/components/ClientContractItemsTab.tsx
Normal file
361
src/pages/clients/components/ClientContractItemsTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
258
src/pages/clients/components/ClientContractsTab.tsx
Normal file
258
src/pages/clients/components/ClientContractsTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
src/pages/clients/components/ClientProfilesTab.tsx
Normal file
302
src/pages/clients/components/ClientProfilesTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
255
src/pages/clients/components/ClientProjectsTab.tsx
Normal file
255
src/pages/clients/components/ClientProjectsTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
0
src/pages/contracts/.gitkeep
Normal file
0
src/pages/contracts/.gitkeep
Normal file
153
src/pages/contracts/ContractCreatePage.tsx
Normal file
153
src/pages/contracts/ContractCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/pages/contracts/ContractDetailDrawer.tsx
Normal file
47
src/pages/contracts/ContractDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
src/pages/contracts/ContractEditPage.tsx
Normal file
228
src/pages/contracts/ContractEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/pages/contracts/ContractsPage.tsx
Normal file
250
src/pages/contracts/ContractsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/pages/dashboard/.gitkeep
Normal file
0
src/pages/dashboard/.gitkeep
Normal file
429
src/pages/dashboard/DashboardPage.tsx
Normal file
429
src/pages/dashboard/DashboardPage.tsx
Normal 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 `há ${diffMinutes}min`;
|
||||
if (diffHours < 24) return `há ${diffHours}h`;
|
||||
if (diffDays < 30) return `há ${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} · {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>·</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>
|
||||
);
|
||||
}
|
||||
0
src/pages/entregaveis/.gitkeep
Normal file
0
src/pages/entregaveis/.gitkeep
Normal file
361
src/pages/entregaveis/EntregaveisListPage.tsx
Normal file
361
src/pages/entregaveis/EntregaveisListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
src/pages/entregaveis/EntregavelCreatePage.tsx
Normal file
362
src/pages/entregaveis/EntregavelCreatePage.tsx
Normal 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">
|
||||
Nº 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>
|
||||
);
|
||||
}
|
||||
292
src/pages/entregaveis/EntregavelDetailPage.tsx
Normal file
292
src/pages/entregaveis/EntregavelDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
src/pages/entregaveis/EntregavelEditPage.tsx
Normal file
383
src/pages/entregaveis/EntregavelEditPage.tsx
Normal 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">
|
||||
Nº 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
118
src/pages/entregaveis/__tests__/EntregavelCreatePage.test.tsx
Normal file
118
src/pages/entregaveis/__tests__/EntregavelCreatePage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
211
src/pages/entregaveis/__tests__/EntregavelDetailPage.test.tsx
Normal file
211
src/pages/entregaveis/__tests__/EntregavelDetailPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
195
src/pages/entregaveis/components/AssignmentModal.tsx
Normal file
195
src/pages/entregaveis/components/AssignmentModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
162
src/pages/entregaveis/components/BacklogItemModal.tsx
Normal file
162
src/pages/entregaveis/components/BacklogItemModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
134
src/pages/entregaveis/components/BacklogRejectModal.tsx
Normal file
134
src/pages/entregaveis/components/BacklogRejectModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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 nº 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>
|
||||
);
|
||||
}
|
||||
435
src/pages/entregaveis/components/EntregavelAllocationTab.tsx
Normal file
435
src/pages/entregaveis/components/EntregavelAllocationTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
355
src/pages/entregaveis/components/EntregavelBacklogTab.tsx
Normal file
355
src/pages/entregaveis/components/EntregavelBacklogTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
244
src/pages/entregaveis/components/EntregavelNotesTab.tsx
Normal file
244
src/pages/entregaveis/components/EntregavelNotesTab.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
src/pages/entregaveis/components/EntregavelSummaryTab.tsx
Normal file
150
src/pages/entregaveis/components/EntregavelSummaryTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/pages/entregaveis/components/EntregavelTeamTab.tsx
Normal file
190
src/pages/entregaveis/components/EntregavelTeamTab.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
src/pages/entregaveis/components/EntregavelTimelineTab.tsx
Normal file
202
src/pages/entregaveis/components/EntregavelTimelineTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/pages/entregaveis/components/EntregavelValuationCard.tsx
Normal file
187
src/pages/entregaveis/components/EntregavelValuationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/pages/entregaveis/components/NoteModal.tsx
Normal file
187
src/pages/entregaveis/components/NoteModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
15
src/pages/entregaveis/components/PlaceholderTab.tsx
Normal file
15
src/pages/entregaveis/components/PlaceholderTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
97
src/pages/fiscal-contrato/FiscalContratoPage.tsx
Normal file
97
src/pages/fiscal-contrato/FiscalContratoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
102
src/pages/gestor-contrato/GestorContratoPage.tsx
Normal file
102
src/pages/gestor-contrato/GestorContratoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
48
src/pages/ordens-servico/OrdemServicoCreatePage.tsx
Normal file
48
src/pages/ordens-servico/OrdemServicoCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/pages/ordens-servico/OrdemServicoDetailPage.tsx
Normal file
232
src/pages/ordens-servico/OrdemServicoDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/pages/ordens-servico/OrdemServicoEditPage.tsx
Normal file
83
src/pages/ordens-servico/OrdemServicoEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/pages/ordens-servico/OrdensServicoListPage.tsx
Normal file
274
src/pages/ordens-servico/OrdensServicoListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
61
src/pages/ordens-servico/components/CancelWorkOrderModal.tsx
Normal file
61
src/pages/ordens-servico/components/CancelWorkOrderModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
365
src/pages/ordens-servico/components/OrdemServicoForm.tsx
Normal file
365
src/pages/ordens-servico/components/OrdemServicoForm.tsx
Normal 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 só 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
104
src/pages/ordens-servico/components/OrdemServicoSummaryTab.tsx
Normal file
104
src/pages/ordens-servico/components/OrdemServicoSummaryTab.tsx
Normal 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
97
src/pages/po/PoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/pages/po/__tests__/PoPage.test.tsx
Normal file
88
src/pages/po/__tests__/PoPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
0
src/pages/professionals/.gitkeep
Normal file
0
src/pages/professionals/.gitkeep
Normal file
113
src/pages/professionals/ProfessionalCreatePage.tsx
Normal file
113
src/pages/professionals/ProfessionalCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/pages/professionals/ProfessionalDetailDrawer.tsx
Normal file
43
src/pages/professionals/ProfessionalDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
src/pages/professionals/ProfessionalEditPage.tsx
Normal file
185
src/pages/professionals/ProfessionalEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/pages/professionals/ProfessionalsPage.tsx
Normal file
248
src/pages/professionals/ProfessionalsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/pages/projects/.gitkeep
Normal file
0
src/pages/projects/.gitkeep
Normal file
138
src/pages/projects/ProjectCreatePage.tsx
Normal file
138
src/pages/projects/ProjectCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/pages/projects/ProjectDetailDrawer.tsx
Normal file
42
src/pages/projects/ProjectDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/pages/projects/ProjectEditPage.tsx
Normal file
209
src/pages/projects/ProjectEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/pages/projects/ProjectsPage.tsx
Normal file
250
src/pages/projects/ProjectsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/pages/settings/.gitkeep
Normal file
0
src/pages/settings/.gitkeep
Normal file
12
src/pages/settings/SettingsPage.tsx
Normal file
12
src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/pages/sprints/.gitkeep
Normal file
0
src/pages/sprints/.gitkeep
Normal file
112
src/pages/sprints/SprintCreatePage.tsx
Normal file
112
src/pages/sprints/SprintCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
src/pages/sprints/SprintDetailPage.tsx
Normal file
426
src/pages/sprints/SprintDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/pages/sprints/SprintEditPage.tsx
Normal file
191
src/pages/sprints/SprintEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/pages/sprints/SprintsPage.tsx
Normal file
191
src/pages/sprints/SprintsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/pages/sprints/components/AddServiceOrderModal.tsx
Normal file
145
src/pages/sprints/components/AddServiceOrderModal.tsx
Normal 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
Reference in New Issue
Block a user