Files
Frontend-Iasis/src/pages/ordens-servico/OrdemServicoDetailPage.tsx

233 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}