Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user