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