Commit inicial - upload de todos os arquivos da pasta

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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