"use client"; import Link from "next/link"; import { useMemo, useState } from "react"; import type { ContractCreateInput, ContractDeliverableView, ContractKpiSnapshot, ContractPaymentView, ContractRecordView, } from "@/lib/contracts/types"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog } from "@/components/ui/dialog"; import { Tabs } from "@/components/ui/tabs"; type ContractsManagementViewProps = { initialContracts: ContractRecordView[]; initialKpis: ContractKpiSnapshot; proposalPdfOptions: Array<{ proposalId: string; proposalTitle: string; documentId: string; fileName: string; createdAt: string; }>; }; type ApiPayload = { ok?: boolean; error?: string; contracts?: ContractRecordView[]; contract?: ContractRecordView | null; document?: { id: string; contractId: string; fileName: string; filePath: string; mimeType: string; sizeBytes: number; checksumSha256: string | null; kind: ContractRecordView["documents"][number]["kind"]; createdAt: string; }; kpis?: ContractKpiSnapshot; extraction?: { warnings?: Array<{ code: string; message: string }>; fields?: { title?: string | null; counterpartyEntity?: string | null; contractNumber?: string | null; contractType?: string | null; totalAmount?: number | null; }; }; }; function formatDate(value: string | null) { if (!value) { return "Sin fecha"; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return "Sin fecha"; } return parsed.toLocaleDateString("es-MX", { year: "numeric", month: "short", day: "2-digit", }); } function formatCurrency(amount: number, currency = "MXN") { return new Intl.NumberFormat("es-MX", { style: "currency", currency, maximumFractionDigits: 2, }).format(amount); } function formatDateTime(value: string) { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return "Sin fecha"; } return parsed.toLocaleString("es-MX", { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function formatFileSize(bytes: number) { if (!Number.isFinite(bytes) || bytes <= 0) { return "N/D"; } if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function contractDocumentKindLabel(kind: ContractRecordView["documents"][number]["kind"]) { if (kind === "SIGNED_CONTRACT") { return "Contrato firmado"; } if (kind === "ADDENDUM") { return "Convenio / adenda"; } if (kind === "DELIVERABLE_EVIDENCE") { return "Evidencia de entregable"; } if (kind === "PAYMENT_EVIDENCE") { return "Evidencia de pago"; } return "Otro"; } function statusTone(status: ContractRecordView["status"]) { if (status === "ACTIVE") { return "border-[#bce5d1] bg-[#ebf9f1] text-[#1e8b63]"; } if (status === "COMPLETED") { return "border-[#c7d8f5] bg-[#ecf3ff] text-[#2c59a8]"; } if (status === "PAUSED") { return "border-[#f0dfb8] bg-[#fff9ea] text-[#946807]"; } return "border-[#e6c9cf] bg-[#fff2f4] text-[#b04a58]"; } export function ContractsManagementView({ initialContracts, initialKpis, proposalPdfOptions }: ContractsManagementViewProps) { const [contracts, setContracts] = useState(initialContracts); const [kpis, setKpis] = useState(initialKpis); const [isBusy, setIsBusy] = useState(false); const [message, setMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [extractionSummary, setExtractionSummary] = useState(null); const [uploadContractId, setUploadContractId] = useState(""); const [uploadFile, setUploadFile] = useState(null); const [proposalSourceId, setProposalSourceId] = useState(proposalPdfOptions[0]?.proposalId ?? ""); const [evidenceContractId, setEvidenceContractId] = useState(""); const [evidenceKind, setEvidenceKind] = useState("SIGNED_CONTRACT"); const [evidenceFile, setEvidenceFile] = useState(null); const [newContract, setNewContract] = useState({ sourceProposalId: null, title: "", counterpartyEntity: "", contractNumber: null, contractType: "General", startDate: null, endDate: null, totalAmount: null, currency: "MXN", status: "ACTIVE", description: "", }); const [newDeliverable, setNewDeliverable] = useState({ contractId: "", title: "", dueDate: "", amountLinked: "", notes: "", }); const [newPayment, setNewPayment] = useState({ contractId: "", amount: "", paymentDate: "", invoiceNumber: "", concept: "", status: "REGISTERED", }); const deliverables = useMemo(() => { const rows: Array = []; for (const contract of contracts) { for (const item of contract.deliverables) { rows.push({ ...item, contractTitle: contract.title, contractId: contract.id, }); } } return rows.sort((a, b) => { const left = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; const right = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; return left - right; }); }, [contracts]); const payments = useMemo(() => { const rows: Array = []; for (const contract of contracts) { for (const item of contract.payments) { rows.push({ ...item, contractTitle: contract.title, currency: contract.currency, }); } } return rows.sort((a, b) => new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime()); }, [contracts]); const selectedProposalPdfOption = useMemo( () => proposalPdfOptions.find((item) => item.proposalId === proposalSourceId) ?? null, [proposalPdfOptions, proposalSourceId], ); const hasContractsWithoutProposalTrace = useMemo( () => contracts.some((item) => !item.sourceProposalId), [contracts], ); async function reloadData() { const [contractsResponse, kpisResponse] = await Promise.all([fetch("/api/contracts"), fetch("/api/contracts/kpis")]); const contractsPayload = (await contractsResponse.json().catch(() => ({}))) as ApiPayload; const kpisPayload = (await kpisResponse.json().catch(() => ({}))) as ApiPayload; if (contractsResponse.ok && contractsPayload.ok && contractsPayload.contracts) { setContracts(contractsPayload.contracts); } if (kpisResponse.ok && kpisPayload.ok && kpisPayload.kpis) { setKpis(kpisPayload.kpis); } } async function createContract() { setErrorMessage(null); setMessage(null); setIsBusy(true); try { const response = await fetch("/api/contracts", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ ...newContract, totalAmount: newContract.totalAmount === null ? null : Number(newContract.totalAmount), }), }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible crear el contrato."); return; } setMessage("Contrato creado correctamente."); setNewContract({ sourceProposalId: null, title: "", counterpartyEntity: "", contractNumber: null, contractType: "General", startDate: null, endDate: null, totalAmount: null, currency: "MXN", status: "ACTIVE", description: "", }); await reloadData(); } catch { setErrorMessage("No fue posible crear el contrato."); } finally { setIsBusy(false); } } async function updateContractStatus(contractId: string, status: ContractRecordView["status"]) { setErrorMessage(null); setMessage(null); const response = await fetch(`/api/contracts/${encodeURIComponent(contractId)}`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ status }), }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible actualizar el estado."); return; } setMessage("Estado de contrato actualizado."); await reloadData(); } async function performExtraction(formData: FormData, successMessage: string) { setErrorMessage(null); setMessage(null); setExtractionSummary(null); setIsBusy(true); try { const response = await fetch("/api/contracts/extract", { method: "POST", body: formData, }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible analizar el contrato."); return false; } const extraction = payload.extraction; const fields = extraction?.fields; const warningText = extraction?.warnings?.map((item) => item.message).join(" | ") ?? ""; setExtractionSummary( [ fields?.title ? `Titulo: ${fields.title}` : "", fields?.counterpartyEntity ? `Entidad: ${fields.counterpartyEntity}` : "", fields?.contractNumber ? `Numero: ${fields.contractNumber}` : "", fields?.contractType ? `Tipo: ${fields.contractType}` : "", fields?.totalAmount ? `Monto: ${fields.totalAmount}` : "", warningText ? `Notas: ${warningText}` : "", ] .filter(Boolean) .join(" • "), ); setMessage(successMessage); await reloadData(); return true; } catch { setErrorMessage("No fue posible analizar el contrato."); return false; } finally { setIsBusy(false); } } async function submitExtraction() { const currentFile = uploadFile; if (!currentFile) { setErrorMessage("Selecciona un PDF para extraer."); return; } const formData = new FormData(); formData.append("file", currentFile); if (uploadContractId) { formData.append("contractId", uploadContractId); } const ok = await performExtraction(formData, "Contrato analizado y guardado."); if (ok) { setUploadFile(null); setUploadContractId(""); } } async function submitExtractionFromProposal() { if (!proposalSourceId) { setErrorMessage("Selecciona una propuesta con PDF para continuar."); return; } const formData = new FormData(); formData.append("sourceProposalId", proposalSourceId); if (uploadContractId) { formData.append("contractId", uploadContractId); } const ok = await performExtraction(formData, "Contrato analizado usando PDF de propuesta (M5)."); if (ok) { setUploadFile(null); } } async function uploadContractEvidence() { if (!evidenceContractId) { setErrorMessage("Selecciona un contrato para adjuntar evidencia."); return; } if (!evidenceFile) { setErrorMessage("Selecciona un archivo PDF para adjuntar evidencia."); return; } setIsBusy(true); setErrorMessage(null); setMessage(null); try { const formData = new FormData(); formData.append("contractId", evidenceContractId); formData.append("kind", evidenceKind); formData.append("file", evidenceFile); const response = await fetch("/api/contracts/upload", { method: "POST", body: formData, }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok || !payload.document) { setErrorMessage(payload.error ?? "No fue posible adjuntar el documento al contrato."); return; } setMessage("Documento adjuntado al contrato."); setEvidenceFile(null); await reloadData(); } catch { setErrorMessage("No fue posible adjuntar el documento al contrato."); } finally { setIsBusy(false); } } async function deleteContractDocument(contractId: string, documentId: string) { if (!window.confirm("Se eliminara este documento del contrato. Deseas continuar?")) { return; } setErrorMessage(null); setMessage(null); setIsBusy(true); try { const response = await fetch(`/api/contracts/${encodeURIComponent(contractId)}/documents/${encodeURIComponent(documentId)}`, { method: "DELETE", }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible eliminar el documento."); return; } setMessage("Documento eliminado."); await reloadData(); } catch { setErrorMessage("No fue posible eliminar el documento."); } finally { setIsBusy(false); } } async function createDeliverable() { if (!newDeliverable.contractId) { setErrorMessage("Selecciona un contrato para el entregable."); return; } setErrorMessage(null); setMessage(null); setIsBusy(true); try { const response = await fetch(`/api/contracts/${encodeURIComponent(newDeliverable.contractId)}/deliverables`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ title: newDeliverable.title, dueDate: newDeliverable.dueDate ? new Date(newDeliverable.dueDate).toISOString() : null, amountLinked: newDeliverable.amountLinked ? Number(newDeliverable.amountLinked) : null, notes: newDeliverable.notes, }), }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible crear el entregable."); return; } setMessage("Entregable registrado."); setNewDeliverable({ contractId: "", title: "", dueDate: "", amountLinked: "", notes: "", }); await reloadData(); } catch { setErrorMessage("No fue posible crear el entregable."); } finally { setIsBusy(false); } } async function updateDeliverableStatus(deliverableId: string, status: ContractDeliverableView["status"]) { setErrorMessage(null); const response = await fetch(`/api/deliverables/${encodeURIComponent(deliverableId)}`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ status }), }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible actualizar el entregable."); return; } setMessage("Entregable actualizado."); await reloadData(); } async function createPayment() { if (!newPayment.contractId) { setErrorMessage("Selecciona un contrato para el pago."); return; } setErrorMessage(null); setMessage(null); setIsBusy(true); try { const response = await fetch(`/api/contracts/${encodeURIComponent(newPayment.contractId)}/payments`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ amount: Number(newPayment.amount), paymentDate: new Date(newPayment.paymentDate).toISOString(), invoiceNumber: newPayment.invoiceNumber || null, concept: newPayment.concept, status: newPayment.status, }), }); const payload = (await response.json().catch(() => ({}))) as ApiPayload; if (!response.ok || !payload.ok) { setErrorMessage(payload.error ?? "No fue posible registrar el pago."); return; } setMessage("Pago registrado."); setNewPayment({ contractId: "", amount: "", paymentDate: "", invoiceNumber: "", concept: "", status: "REGISTERED", }); await reloadData(); } catch { setErrorMessage("No fue posible registrar el pago."); } finally { setIsBusy(false); } } return (

Contratos activos

{kpis.activeContracts}

Total cobrado

{formatCurrency(kpis.totalCollected)}

Entregables pendientes

{kpis.pendingDeliverables}

Entregables vencidos

{kpis.overdueDeliverables}

{errorMessage ?

{errorMessage}

: null} {message ?

{message}

: null} {extractionSummary ?

{extractionSummary}

: null} {proposalPdfOptions.length === 0 ? (

Advertencia de continuidad: no hay propuestas M5 con PDF para reutilizar en M8.

) : null} {hasContractsWithoutProposalTrace ? (

Advertencia de continuidad: hay contratos sin `sourceProposalId`, la trazabilidad M5->M8 puede estar incompleta.

) : null}

Sube contrato PDF para extraer campos, entregables, pagos y clausulas de riesgo.

Continuidad M5 a M8 (sin reupload)

Usa el PDF mas reciente de una propuesta de Modulo 5 como fuente de extraccion.

{selectedProposalPdfOption ? (

Por que este archivo: se usa el PDF mas reciente de la propuesta seleccionada ({selectedProposalPdfOption.fileName}).

) : null}

Adjuntar documento al contrato (sin IA)

Usa este flujo para subir evidencia contractual directamente usando `/api/contracts/upload`.

), }, { id: "contracts", label: "Contratos", content: (
setNewContract((prev) => ({ ...prev, title: event.target.value }))} placeholder="Titulo" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewContract((prev) => ({ ...prev, counterpartyEntity: event.target.value }))} placeholder="Entidad contratante" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewContract((prev) => ({ ...prev, contractNumber: event.target.value || null }))} placeholder="Numero de contrato" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewContract((prev) => ({ ...prev, contractType: event.target.value }))} placeholder="Tipo" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewContract((prev) => ({ ...prev, totalAmount: event.target.value ? Number(event.target.value) : null }))} placeholder="Monto total" type="number" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" />
{contracts.length === 0 ?

Aun no hay contratos registrados.

: null} {contracts.map((contract) => (

{contract.title}

{contract.counterpartyEntity}

{contract.contractType}

{contract.status}

Inicio: {formatDate(contract.startDate)}

Fin: {formatDate(contract.endDate)}

Monto: {contract.totalAmount ? formatCurrency(contract.totalAmount, contract.currency) : "Sin monto"}

Progreso: {contract.progress.completed}/{contract.progress.total} ({contract.progress.percent}%)

Documentos del contrato

{contract.documents.length === 0 ?

Sin documentos cargados.

: null} {contract.documents.length > 0 ? (
    {contract.documents.map((document) => (
  • {document.fileName}

    {contractDocumentKindLabel(document.kind)} | {formatFileSize(document.sizeBytes)} | {formatDateTime(document.createdAt)}

    {contract.sourceProposalId && document.kind === "SIGNED_CONTRACT" ? (

    Por que este archivo: continuidad M5->M8 desde propuesta {contract.sourceProposalId}.

    ) : null}
    Descargar
  • ))}
) : null}
))}
), }, { id: "deliverables", label: "Entregables", content: (
setNewDeliverable((prev) => ({ ...prev, title: event.target.value }))} placeholder="Titulo del entregable" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewDeliverable((prev) => ({ ...prev, dueDate: event.target.value }))} className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewDeliverable((prev) => ({ ...prev, amountLinked: event.target.value }))} placeholder="Monto vinculado" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" />
{deliverables.length === 0 ?

Sin entregables registrados.

: null} {deliverables.map((item) => (

{item.title}

{item.contractTitle}

Vence: {formatDate(item.dueDate)} {item.isOverdue ? "(vencido)" : ""}

))}
), }, { id: "payments", label: "Pagos", content: (
setNewPayment((prev) => ({ ...prev, amount: event.target.value }))} placeholder="Monto" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewPayment((prev) => ({ ...prev, paymentDate: event.target.value }))} className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" /> setNewPayment((prev) => ({ ...prev, concept: event.target.value }))} placeholder="Concepto" className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" />
{payments.length === 0 ?

Sin pagos registrados.

: null} {payments.map((item) => (

{item.contractTitle}

{formatCurrency(item.amount, item.currency)}

{item.concept}

{formatDate(item.paymentDate)} - {item.status}

))}
), }, ]} />
); }