Fixes: - Add teal, cyan, slate colors to StatusColor type and StatusBadge - Create StatsCard component with color prop for backward compatibility - Add label/required props to FormGroup component - Fix Pagination to accept both currentPage and page props - Fix unused imports in quality and contracts pages - Add missing Plus, Trash2, User icon imports in contracts pages - Remove duplicate formatDate function in ContratoDetailPage New components: - StatsCard, StatsCardGrid for statistics display Build: Success (npm run build passes) Dev: Success (npm run dev starts on port 3020) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
853 lines
31 KiB
TypeScript
853 lines
31 KiB
TypeScript
/**
|
|
* ContratoDetailPage - Detalle del contrato con tabs
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft,
|
|
FileText,
|
|
Building2,
|
|
Calendar,
|
|
DollarSign,
|
|
Pencil,
|
|
Send,
|
|
CheckCircle,
|
|
PlayCircle,
|
|
XCircle,
|
|
ListTodo,
|
|
FilePlus,
|
|
Clock,
|
|
Plus,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import {
|
|
useContract,
|
|
useContractPartidas,
|
|
useContractAddendums,
|
|
useSubmitContract,
|
|
useApproveContract,
|
|
useActivateContract,
|
|
useCompleteContract,
|
|
useTerminateContract,
|
|
useDeleteContractPartida,
|
|
useDeleteContractAddendum,
|
|
} from '../../../hooks/useContracts';
|
|
import type { Contract, ContractPartida, ContractAddendum } from '../../../types/contracts.types';
|
|
import {
|
|
CONTRACT_TYPE_OPTIONS,
|
|
CONTRACT_STATUS_OPTIONS,
|
|
ADDENDUM_TYPE_OPTIONS,
|
|
ADDENDUM_STATUS_OPTIONS,
|
|
} from '../../../types/contracts.types';
|
|
import {
|
|
StatusBadgeFromOptions,
|
|
LoadingOverlay,
|
|
EmptyState,
|
|
ConfirmDialog,
|
|
Modal,
|
|
ModalFooter,
|
|
TextareaField,
|
|
} from '../../../components/common';
|
|
import { ContractForm } from '../../../components/contracts/ContractForm';
|
|
import { AddendaModal } from '../../../components/contracts/AddendaModal';
|
|
import { PartidaModal } from '../../../components/contracts/PartidaModal';
|
|
|
|
type TabType = 'info' | 'partidas' | 'addendas';
|
|
|
|
export function ContratoDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [activeTab, setActiveTab] = useState<TabType>('info');
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [showAddendaModal, setShowAddendaModal] = useState(false);
|
|
const [showPartidaModal, setShowPartidaModal] = useState(false);
|
|
const [editingAddenda, setEditingAddenda] = useState<ContractAddendum | null>(null);
|
|
const [editingPartida, setEditingPartida] = useState<ContractPartida | null>(null);
|
|
const [deletePartidaId, setDeletePartidaId] = useState<string | null>(null);
|
|
const [deleteAddendaId, setDeleteAddendaId] = useState<string | null>(null);
|
|
const [showTerminateModal, setShowTerminateModal] = useState(false);
|
|
const [terminateReason, setTerminateReason] = useState('');
|
|
|
|
const { data: contract, isLoading, error } = useContract(id || '');
|
|
const { data: partidas, isLoading: loadingPartidas } = useContractPartidas(id || '');
|
|
const { data: addendums, isLoading: loadingAddendums } = useContractAddendums(id || '');
|
|
|
|
const submitMutation = useSubmitContract();
|
|
const approveMutation = useApproveContract();
|
|
const activateMutation = useActivateContract();
|
|
const completeMutation = useCompleteContract();
|
|
const terminateMutation = useTerminateContract();
|
|
const deletePartidaMutation = useDeleteContractPartida();
|
|
const deleteAddendaMutation = useDeleteContractAddendum();
|
|
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN',
|
|
}).format(value);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (id) await submitMutation.mutateAsync(id);
|
|
};
|
|
|
|
const handleApprove = async () => {
|
|
if (id) await approveMutation.mutateAsync(id);
|
|
};
|
|
|
|
const handleActivate = async () => {
|
|
if (id) await activateMutation.mutateAsync(id);
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
if (id) await completeMutation.mutateAsync(id);
|
|
};
|
|
|
|
const handleTerminate = async () => {
|
|
if (id && terminateReason) {
|
|
await terminateMutation.mutateAsync({ id, reason: terminateReason });
|
|
setShowTerminateModal(false);
|
|
setTerminateReason('');
|
|
}
|
|
};
|
|
|
|
const handleDeletePartida = async () => {
|
|
if (id && deletePartidaId) {
|
|
await deletePartidaMutation.mutateAsync({ contractId: id, partidaId: deletePartidaId });
|
|
setDeletePartidaId(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAddenda = async () => {
|
|
if (id && deleteAddendaId) {
|
|
await deleteAddendaMutation.mutateAsync({ contractId: id, addendumId: deleteAddendaId });
|
|
setDeleteAddendaId(null);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <LoadingOverlay message="Cargando contrato..." />;
|
|
}
|
|
|
|
if (error || !contract) {
|
|
return (
|
|
<EmptyState
|
|
title="Contrato no encontrado"
|
|
description="El contrato solicitado no existe o fue eliminado."
|
|
/>
|
|
);
|
|
}
|
|
|
|
const tabs = [
|
|
{ id: 'info', label: 'Informacion General', icon: FileText },
|
|
{ id: 'partidas', label: 'Partidas', icon: ListTodo, count: partidas?.length },
|
|
{ id: 'addendas', label: 'Addendas', icon: FilePlus, count: addendums?.length },
|
|
];
|
|
|
|
const canSubmit = contract.status === 'draft';
|
|
const canApprove = contract.status === 'review';
|
|
const canActivate = contract.status === 'approved';
|
|
const canComplete = contract.status === 'active';
|
|
const canTerminate = ['active', 'approved'].includes(contract.status);
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<button
|
|
onClick={() => navigate('/admin/contratos')}
|
|
className="flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 mb-4"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Volver a contratos
|
|
</button>
|
|
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
|
<FileText className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
{contract.contractNumber}
|
|
</h1>
|
|
<p className="text-gray-600 dark:text-gray-400">{contract.name}</p>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<StatusBadgeFromOptions
|
|
value={contract.contractType}
|
|
options={[...CONTRACT_TYPE_OPTIONS]}
|
|
/>
|
|
<StatusBadgeFromOptions
|
|
value={contract.status}
|
|
options={[...CONTRACT_STATUS_OPTIONS]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => setShowEditModal(true)}
|
|
className="flex items-center px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
>
|
|
<Pencil className="w-4 h-4 mr-2" />
|
|
Editar
|
|
</button>
|
|
|
|
{canSubmit && (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submitMutation.isPending}
|
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
<Send className="w-4 h-4 mr-2" />
|
|
Enviar a Revision
|
|
</button>
|
|
)}
|
|
|
|
{canApprove && (
|
|
<button
|
|
onClick={handleApprove}
|
|
disabled={approveMutation.isPending}
|
|
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Aprobar
|
|
</button>
|
|
)}
|
|
|
|
{canActivate && (
|
|
<button
|
|
onClick={handleActivate}
|
|
disabled={activateMutation.isPending}
|
|
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
<PlayCircle className="w-4 h-4 mr-2" />
|
|
Activar
|
|
</button>
|
|
)}
|
|
|
|
{canComplete && (
|
|
<button
|
|
onClick={handleComplete}
|
|
disabled={completeMutation.isPending}
|
|
className="flex items-center px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Completar
|
|
</button>
|
|
)}
|
|
|
|
{canTerminate && (
|
|
<button
|
|
onClick={() => setShowTerminateModal(true)}
|
|
className="flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-2" />
|
|
Terminar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Monto Contrato</p>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
|
{formatCurrency(contract.contractAmount)}
|
|
</p>
|
|
</div>
|
|
<DollarSign className="w-8 h-8 text-blue-500" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Facturado</p>
|
|
<p className="text-xl font-bold text-green-600">
|
|
{formatCurrency(contract.invoicedAmount)}
|
|
</p>
|
|
</div>
|
|
<FileText className="w-8 h-8 text-green-500" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Pagado</p>
|
|
<p className="text-xl font-bold text-blue-600">
|
|
{formatCurrency(contract.paidAmount)}
|
|
</p>
|
|
</div>
|
|
<DollarSign className="w-8 h-8 text-blue-500" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Avance</p>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
|
{contract.progressPercentage}%
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 relative">
|
|
<svg className="w-full h-full" viewBox="0 0 36 36">
|
|
<path
|
|
className="text-gray-200 dark:text-gray-700"
|
|
strokeWidth="3"
|
|
stroke="currentColor"
|
|
fill="none"
|
|
d="M18 2.0845a 15.9155 15.9155 0 0 1 0 31.831a 15.9155 15.9155 0 0 1 0 -31.831"
|
|
/>
|
|
<path
|
|
className="text-blue-600"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
stroke="currentColor"
|
|
fill="none"
|
|
strokeDasharray={`${contract.progressPercentage}, 100`}
|
|
d="M18 2.0845a 15.9155 15.9155 0 0 1 0 31.831a 15.9155 15.9155 0 0 1 0 -31.831"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
|
<nav className="flex -mb-px">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as TabType)}
|
|
className={`flex items-center px-6 py-4 border-b-2 font-medium text-sm transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<tab.icon className="w-4 h-4 mr-2" />
|
|
{tab.label}
|
|
{tab.count !== undefined && (
|
|
<span className="ml-2 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded-full">
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{activeTab === 'info' && <ContractInfoTab contract={contract} />}
|
|
{activeTab === 'partidas' && (
|
|
<PartidasTab
|
|
partidas={partidas || []}
|
|
isLoading={loadingPartidas}
|
|
onAdd={() => {
|
|
setEditingPartida(null);
|
|
setShowPartidaModal(true);
|
|
}}
|
|
onEdit={(p) => {
|
|
setEditingPartida(p);
|
|
setShowPartidaModal(true);
|
|
}}
|
|
onDelete={setDeletePartidaId}
|
|
/>
|
|
)}
|
|
{activeTab === 'addendas' && (
|
|
<AddendasTab
|
|
addendums={addendums || []}
|
|
isLoading={loadingAddendums}
|
|
onAdd={() => {
|
|
setEditingAddenda(null);
|
|
setShowAddendaModal(true);
|
|
}}
|
|
onEdit={(a) => {
|
|
setEditingAddenda(a);
|
|
setShowAddendaModal(true);
|
|
}}
|
|
onDelete={setDeleteAddendaId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
{showEditModal && (
|
|
<ContractForm
|
|
contract={contract}
|
|
onClose={() => setShowEditModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{showAddendaModal && (
|
|
<AddendaModal
|
|
contractId={id || ''}
|
|
addendum={editingAddenda}
|
|
onClose={() => {
|
|
setShowAddendaModal(false);
|
|
setEditingAddenda(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showPartidaModal && (
|
|
<PartidaModal
|
|
contractId={id || ''}
|
|
partida={editingPartida}
|
|
onClose={() => {
|
|
setShowPartidaModal(false);
|
|
setEditingPartida(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Terminate Modal */}
|
|
{showTerminateModal && (
|
|
<Modal
|
|
isOpen={true}
|
|
onClose={() => setShowTerminateModal(false)}
|
|
title="Terminar Contrato"
|
|
size="md"
|
|
footer={
|
|
<ModalFooter>
|
|
<button
|
|
type="button"
|
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
onClick={() => setShowTerminateModal(false)}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleTerminate}
|
|
disabled={!terminateReason || terminateMutation.isPending}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{terminateMutation.isPending ? 'Terminando...' : 'Terminar Contrato'}
|
|
</button>
|
|
</ModalFooter>
|
|
}
|
|
>
|
|
<TextareaField
|
|
label="Razon de terminacion"
|
|
required
|
|
value={terminateReason}
|
|
onChange={(e) => setTerminateReason(e.target.value)}
|
|
placeholder="Describa la razon por la cual se termina el contrato..."
|
|
rows={4}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Delete Confirmations */}
|
|
<ConfirmDialog
|
|
isOpen={!!deletePartidaId}
|
|
onClose={() => setDeletePartidaId(null)}
|
|
onConfirm={handleDeletePartida}
|
|
title="Eliminar Partida"
|
|
message="Esta seguro de eliminar esta partida del contrato?"
|
|
confirmLabel="Eliminar"
|
|
variant="danger"
|
|
isLoading={deletePartidaMutation.isPending}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={!!deleteAddendaId}
|
|
onClose={() => setDeleteAddendaId(null)}
|
|
onConfirm={handleDeleteAddenda}
|
|
title="Eliminar Addenda"
|
|
message="Esta seguro de eliminar esta addenda?"
|
|
confirmLabel="Eliminar"
|
|
variant="danger"
|
|
isLoading={deleteAddendaMutation.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CONTRACT INFO TAB
|
|
// ============================================================================
|
|
|
|
function ContractInfoTab({ contract }: { contract: Contract }) {
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('es-MX', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* General Info */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
|
<FileText className="w-5 h-5 mr-2" />
|
|
Datos Generales
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
|
<InfoRow label="Numero de Contrato" value={contract.contractNumber} />
|
|
<InfoRow label="Nombre" value={contract.name} />
|
|
{contract.description && (
|
|
<InfoRow label="Descripcion" value={contract.description} />
|
|
)}
|
|
<InfoRow label="Moneda" value={contract.currency} />
|
|
<InfoRow label="Retencion" value={`${contract.retentionPercentage}%`} />
|
|
<InfoRow label="Anticipo" value={`${contract.advancePercentage}%`} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Client/Subcontractor Info */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
|
<Building2 className="w-5 h-5 mr-2" />
|
|
{contract.contractType === 'client' ? 'Datos del Cliente' : 'Datos del Subcontratista'}
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
|
{contract.contractType === 'client' ? (
|
|
<>
|
|
<InfoRow label="Cliente" value={contract.clientName || '-'} />
|
|
<InfoRow label="RFC" value={contract.clientRfc || '-'} />
|
|
<InfoRow label="Direccion" value={contract.clientAddress || '-'} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<InfoRow label="Subcontratista" value={contract.subcontractor?.businessName || '-'} />
|
|
<InfoRow label="RFC" value={contract.subcontractor?.rfc || '-'} />
|
|
<InfoRow label="Especialidad" value={contract.specialty || '-'} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
|
<Calendar className="w-5 h-5 mr-2" />
|
|
Vigencia
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
|
<InfoRow label="Fecha Inicio" value={formatDate(contract.startDate)} />
|
|
<InfoRow label="Fecha Fin" value={formatDate(contract.endDate)} />
|
|
{contract.signedAt && (
|
|
<InfoRow label="Fecha Firma" value={formatDate(contract.signedAt)} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment Terms */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
|
<DollarSign className="w-5 h-5 mr-2" />
|
|
Condiciones de Pago
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
{contract.paymentTerms || 'Sin condiciones especificas'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Audit Info */}
|
|
<div className="space-y-4 lg:col-span-2">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
|
<Clock className="w-5 h-5 mr-2" />
|
|
Historial
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
|
<InfoRow label="Creado" value={formatDate(contract.createdAt)} />
|
|
<InfoRow label="Actualizado" value={formatDate(contract.updatedAt)} />
|
|
{contract.submittedAt && (
|
|
<InfoRow label="Enviado a revision" value={formatDate(contract.submittedAt)} />
|
|
)}
|
|
{contract.approvedAt && (
|
|
<InfoRow label="Aprobado" value={formatDate(contract.approvedAt)} />
|
|
)}
|
|
{contract.terminatedAt && (
|
|
<>
|
|
<InfoRow label="Terminado" value={formatDate(contract.terminatedAt)} />
|
|
<InfoRow label="Razon" value={contract.terminationReason || '-'} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
{contract.notes && (
|
|
<div className="space-y-4 lg:col-span-2">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Notas
|
|
</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
|
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
|
{contract.notes}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// PARTIDAS TAB
|
|
// ============================================================================
|
|
|
|
interface PartidasTabProps {
|
|
partidas: ContractPartida[];
|
|
isLoading: boolean;
|
|
onAdd: () => void;
|
|
onEdit: (partida: ContractPartida) => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
function PartidasTab({ partidas, isLoading, onAdd, onEdit, onDelete }: PartidasTabProps) {
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN',
|
|
}).format(value);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="text-center py-8 text-gray-500">Cargando partidas...</div>;
|
|
}
|
|
|
|
const total = partidas.reduce((sum, p) => sum + (p.totalAmount || p.quantity * p.unitPrice), 0);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Partidas del Contrato
|
|
</h3>
|
|
<button
|
|
onClick={onAdd}
|
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Agregar Partida
|
|
</button>
|
|
</div>
|
|
|
|
{partidas.length === 0 ? (
|
|
<EmptyState
|
|
icon={<ListTodo className="w-12 h-12 text-gray-400" />}
|
|
title="Sin partidas"
|
|
description="Agrega las partidas del contrato."
|
|
/>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
|
Concepto
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
|
Cantidad
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
|
P.U.
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
|
Total
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
|
Acciones
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{partidas.map((partida) => (
|
|
<tr key={partida.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-4 py-3">
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{partida.conceptoCode || 'N/A'}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{partida.conceptoDescription || '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-100">
|
|
{partida.quantity.toLocaleString()} {partida.unit || ''}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-100">
|
|
{formatCurrency(partida.unitPrice)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{formatCurrency(partida.totalAmount || partida.quantity * partida.unitPrice)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => onEdit(partida)}
|
|
className="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(partida.id)}
|
|
className="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<td colSpan={3} className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
Total:
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900 dark:text-gray-100">
|
|
{formatCurrency(total)}
|
|
</td>
|
|
<td></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// ADDENDAS TAB
|
|
// ============================================================================
|
|
|
|
interface AddendasTabProps {
|
|
addendums: ContractAddendum[];
|
|
isLoading: boolean;
|
|
onAdd: () => void;
|
|
onEdit: (addendum: ContractAddendum) => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
function AddendasTab({ addendums, isLoading, onAdd, onEdit, onDelete }: AddendasTabProps) {
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN',
|
|
}).format(value);
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('es-MX', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="text-center py-8 text-gray-500">Cargando addendas...</div>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
Addendas del Contrato
|
|
</h3>
|
|
<button
|
|
onClick={onAdd}
|
|
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nueva Addenda
|
|
</button>
|
|
</div>
|
|
|
|
{addendums.length === 0 ? (
|
|
<EmptyState
|
|
icon={<FilePlus className="w-12 h-12 text-gray-400" />}
|
|
title="Sin addendas"
|
|
description="No hay addendas registradas para este contrato."
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{addendums.map((addendum) => (
|
|
<div
|
|
key={addendum.id}
|
|
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
{addendum.addendumNumber}
|
|
</span>
|
|
<StatusBadgeFromOptions
|
|
value={addendum.addendumType}
|
|
options={[...ADDENDUM_TYPE_OPTIONS]}
|
|
/>
|
|
<StatusBadgeFromOptions
|
|
value={addendum.status}
|
|
options={[...ADDENDUM_STATUS_OPTIONS]}
|
|
/>
|
|
</div>
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">
|
|
{addendum.title}
|
|
</h4>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
{addendum.description}
|
|
</p>
|
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Vigencia: {formatDate(addendum.effectiveDate)}</span>
|
|
{addendum.amountChange !== 0 && (
|
|
<span className={addendum.amountChange > 0 ? 'text-green-600' : 'text-red-600'}>
|
|
{addendum.amountChange > 0 ? '+' : ''}{formatCurrency(addendum.amountChange)}
|
|
</span>
|
|
)}
|
|
{addendum.newEndDate && (
|
|
<span>Nueva fecha fin: {formatDate(addendum.newEndDate)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 ml-4">
|
|
<button
|
|
onClick={() => onEdit(addendum)}
|
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(addendum.id)}
|
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|