Files
Kontia/src/components/app/contracts-management-view.tsx
Marcelo Dares ea23136288 changes
2026-04-29 01:15:50 +02:00

1047 lines
40 KiB
TypeScript

"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<ContractRecordView[]>(initialContracts);
const [kpis, setKpis] = useState<ContractKpiSnapshot>(initialKpis);
const [isBusy, setIsBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [extractionSummary, setExtractionSummary] = useState<string | null>(null);
const [uploadContractId, setUploadContractId] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [proposalSourceId, setProposalSourceId] = useState(proposalPdfOptions[0]?.proposalId ?? "");
const [evidenceContractId, setEvidenceContractId] = useState("");
const [evidenceKind, setEvidenceKind] = useState<ContractRecordView["documents"][number]["kind"]>("SIGNED_CONTRACT");
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [newContract, setNewContract] = useState<ContractCreateInput>({
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<ContractDeliverableView & { contractTitle: string; contractId: string }> = [];
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<ContractPaymentView & { contractTitle: string; currency: string }> = [];
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 (
<div className="space-y-4">
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Contratos activos</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.activeContracts}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Total cobrado</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{formatCurrency(kpis.totalCollected)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Entregables pendientes</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.pendingDeliverables}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Entregables vencidos</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.overdueDeliverables}</p>
</CardContent>
</Card>
</section>
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
{extractionSummary ? <p className="rounded-xl border border-[#ced9ec] bg-[#f3f7ff] px-3 py-2 text-sm text-[#2b4a7b]">{extractionSummary}</p> : null}
{proposalPdfOptions.length === 0 ? (
<p className="rounded-xl border border-[#f0deb0] bg-[#fff8e9] px-3 py-2 text-sm text-[#8d6308]">
Advertencia de continuidad: no hay propuestas M5 con PDF para reutilizar en M8.
</p>
) : null}
{hasContractsWithoutProposalTrace ? (
<p className="rounded-xl border border-[#f0deb0] bg-[#fff8e9] px-3 py-2 text-sm text-[#8d6308]">
Advertencia de continuidad: hay contratos sin `sourceProposalId`, la trazabilidad M5-&gt;M8 puede estar incompleta.
</p>
) : null}
<Tabs
defaultTab="upload"
items={[
{
id: "upload",
label: "Subir Contrato",
content: (
<Card>
<CardContent className="space-y-4 py-5">
<p className="text-sm text-[#5c6d8a]">Sube contrato PDF para extraer campos, entregables, pagos y clausulas de riesgo.</p>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Contrato existente (opcional)
<select
value={uploadContractId}
onChange={(event) => setUploadContractId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3"
>
<option value="">Crear nuevo contrato desde extraccion</option>
{contracts.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Archivo PDF
<input
type="file"
accept="application/pdf"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-lg border border-[#cfd8e7] bg-white px-3 py-2 text-sm"
/>
</label>
<Button onClick={submitExtraction} disabled={isBusy || !uploadFile}>
{isBusy ? "Analizando..." : "Analizar y guardar"}
</Button>
<div className="h-px bg-[#dfe7f3]" />
<div className="space-y-2 rounded-xl border border-[#d6e2f3] bg-[#f4f8ff] p-3">
<p className="text-sm font-semibold text-[#2d4d7d]">Continuidad M5 a M8 (sin reupload)</p>
<p className="text-xs text-[#4a638c]">Usa el PDF mas reciente de una propuesta de Modulo 5 como fuente de extraccion.</p>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Propuesta fuente (M5)
<select
value={proposalSourceId}
onChange={(event) => setProposalSourceId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3"
>
{proposalPdfOptions.length === 0 ? <option value="">No hay propuestas con PDF disponible</option> : null}
{proposalPdfOptions.map((option) => (
<option key={option.proposalId} value={option.proposalId}>
{option.proposalTitle} - {option.fileName} ({formatDateTime(option.createdAt)})
</option>
))}
</select>
</label>
<Button variant="secondary" onClick={submitExtractionFromProposal} disabled={isBusy || !proposalSourceId}>
{isBusy ? "Analizando..." : "Usar PDF de propuesta de M5"}
</Button>
{selectedProposalPdfOption ? (
<p className="rounded-lg border border-[#cfdbef] bg-white px-2 py-1 text-xs text-[#3f5d8a]">
<span className="font-semibold">Por que este archivo:</span> se usa el PDF mas reciente de la propuesta seleccionada ({selectedProposalPdfOption.fileName}).
</p>
) : null}
</div>
<div className="h-px bg-[#dfe7f3]" />
<div className="space-y-2 rounded-xl border border-[#e0d9fb] bg-[#f8f5ff] p-3">
<p className="text-sm font-semibold text-[#4f3f9a]">Adjuntar documento al contrato (sin IA)</p>
<p className="text-xs text-[#6a62a3]">Usa este flujo para subir evidencia contractual directamente usando `/api/contracts/upload`.</p>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Contrato destino
<select
value={evidenceContractId}
onChange={(event) => setEvidenceContractId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3"
>
<option value="">Selecciona contrato</option>
{contracts.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Tipo de documento
<select
value={evidenceKind}
onChange={(event) => setEvidenceKind(event.target.value as ContractRecordView["documents"][number]["kind"])}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3"
>
<option value="SIGNED_CONTRACT">Contrato firmado</option>
<option value="ADDENDUM">Convenio / adenda</option>
<option value="DELIVERABLE_EVIDENCE">Evidencia de entregable</option>
<option value="PAYMENT_EVIDENCE">Evidencia de pago</option>
<option value="OTHER">Otro</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#314764]">
Archivo PDF de evidencia
<input
type="file"
accept="application/pdf"
onChange={(event) => setEvidenceFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-lg border border-[#cfd8e7] bg-white px-3 py-2 text-sm"
/>
</label>
<Button variant="secondary" onClick={uploadContractEvidence} disabled={isBusy || !evidenceContractId || !evidenceFile}>
{isBusy ? "Subiendo..." : "Subir evidencia al contrato"}
</Button>
</div>
</CardContent>
</Card>
),
},
{
id: "contracts",
label: "Contratos",
content: (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Dialog triggerLabel="Nuevo Contrato" title="Nuevo Contrato" description="Captura manual del contrato">
<div className="space-y-2">
<input
value={newContract.title}
onChange={(event) => setNewContract((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Titulo"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newContract.counterpartyEntity}
onChange={(event) => setNewContract((prev) => ({ ...prev, counterpartyEntity: event.target.value }))}
placeholder="Entidad contratante"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newContract.contractNumber ?? ""}
onChange={(event) => setNewContract((prev) => ({ ...prev, contractNumber: event.target.value || null }))}
placeholder="Numero de contrato"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newContract.contractType}
onChange={(event) => setNewContract((prev) => ({ ...prev, contractType: event.target.value }))}
placeholder="Tipo"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newContract.totalAmount ?? ""}
onChange={(event) => 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"
/>
<Button onClick={createContract} disabled={isBusy}>
Guardar contrato
</Button>
</div>
</Dialog>
</div>
{contracts.length === 0 ? <p className="text-sm text-[#60718f]">Aun no hay contratos registrados.</p> : null}
{contracts.map((contract) => (
<article key={contract.id} className="rounded-2xl border border-[#d8e1ef] bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-lg font-semibold text-[#1f3050]">{contract.title}</p>
<p className="text-sm text-[#60718f]">{contract.counterpartyEntity}</p>
<p className="text-xs text-[#60718f]">{contract.contractType}</p>
</div>
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${statusTone(contract.status)}`}>{contract.status}</span>
</div>
<div className="mt-3 grid gap-2 text-sm text-[#425a82] md:grid-cols-4">
<p>Inicio: {formatDate(contract.startDate)}</p>
<p>Fin: {formatDate(contract.endDate)}</p>
<p>Monto: {contract.totalAmount ? formatCurrency(contract.totalAmount, contract.currency) : "Sin monto"}</p>
<p>
Progreso: {contract.progress.completed}/{contract.progress.total} ({contract.progress.percent}%)
</p>
</div>
<div className="mt-3 rounded-xl border border-[#dbe4f2] bg-[#f8fbff] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#4b6287]">Documentos del contrato</p>
{contract.documents.length === 0 ? <p className="mt-2 text-xs text-[#60718f]">Sin documentos cargados.</p> : null}
{contract.documents.length > 0 ? (
<ul className="mt-2 space-y-2">
{contract.documents.map((document) => (
<li key={document.id} className="rounded-lg border border-[#d8e2ef] bg-white px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[#1f3050]">{document.fileName}</p>
<p className="text-xs text-[#60718f]">
{contractDocumentKindLabel(document.kind)} | {formatFileSize(document.sizeBytes)} | {formatDateTime(document.createdAt)}
</p>
{contract.sourceProposalId && document.kind === "SIGNED_CONTRACT" ? (
<p className="mt-1 rounded-md border border-[#d3e3fa] bg-[#eef5ff] px-2 py-1 text-[11px] font-semibold text-[#2c5b9e]">
Por que este archivo: continuidad M5-&gt;M8 desde propuesta {contract.sourceProposalId}.
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<a
href={`/api/contracts/${encodeURIComponent(contract.id)}/documents/${encodeURIComponent(document.id)}`}
className="inline-flex h-8 items-center rounded-lg border border-[#cfd8e7] px-2 text-xs font-semibold text-[#2f4a74]"
>
Descargar
</a>
<button
type="button"
className="inline-flex h-8 items-center rounded-lg border border-[#ebc9ce] bg-[#fff4f6] px-2 text-xs font-semibold text-[#ab3e4d]"
onClick={() => {
void deleteContractDocument(contract.id, document.id);
}}
>
Eliminar
</button>
</div>
</div>
</li>
))}
</ul>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={contract.status}
onChange={(event) => updateContractStatus(contract.id, event.target.value as ContractRecordView["status"])}
className="h-9 rounded-lg border border-[#cfd8e7] bg-white px-2 text-sm"
>
<option value="ACTIVE">ACTIVE</option>
<option value="COMPLETED">COMPLETED</option>
<option value="PAUSED">PAUSED</option>
<option value="CANCELLED">CANCELLED</option>
</select>
<Link href={`/proteccion-legal?contractId=${encodeURIComponent(contract.id)}`}>
<Button size="sm" variant="secondary">
Abrir caso legal
</Button>
</Link>
</div>
</article>
))}
</div>
),
},
{
id: "deliverables",
label: "Entregables",
content: (
<div className="space-y-3">
<Dialog triggerLabel="Nuevo Entregable" title="Nuevo Entregable" description="Vincula un entregable a un contrato">
<div className="space-y-2">
<select
value={newDeliverable.contractId}
onChange={(event) => setNewDeliverable((prev) => ({ ...prev, contractId: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
>
<option value="">Selecciona contrato</option>
{contracts.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
<input
value={newDeliverable.title}
onChange={(event) => setNewDeliverable((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Titulo del entregable"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
type="date"
value={newDeliverable.dueDate}
onChange={(event) => setNewDeliverable((prev) => ({ ...prev, dueDate: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
type="number"
value={newDeliverable.amountLinked}
onChange={(event) => setNewDeliverable((prev) => ({ ...prev, amountLinked: event.target.value }))}
placeholder="Monto vinculado"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<Button onClick={createDeliverable} disabled={isBusy}>
Guardar entregable
</Button>
</div>
</Dialog>
{deliverables.length === 0 ? <p className="text-sm text-[#60718f]">Sin entregables registrados.</p> : null}
{deliverables.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
<p className="text-xs text-[#60718f]">{item.contractTitle}</p>
</div>
<select
value={item.status}
onChange={(event) => updateDeliverableStatus(item.id, event.target.value as ContractDeliverableView["status"])}
className="h-8 rounded-lg border border-[#cfd8e7] bg-white px-2 text-xs"
>
<option value="PENDING">PENDING</option>
<option value="DELIVERED">DELIVERED</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
<option value="OVERDUE">OVERDUE</option>
</select>
</div>
<p className="mt-1 text-xs text-[#60718f]">Vence: {formatDate(item.dueDate)} {item.isOverdue ? "(vencido)" : ""}</p>
</article>
))}
</div>
),
},
{
id: "payments",
label: "Pagos",
content: (
<div className="space-y-3">
<Dialog triggerLabel="Registrar Pago" title="Registrar Pago" description="Agrega un pago vinculado al contrato">
<div className="space-y-2">
<select
value={newPayment.contractId}
onChange={(event) => setNewPayment((prev) => ({ ...prev, contractId: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
>
<option value="">Selecciona contrato</option>
{contracts.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
<input
type="number"
value={newPayment.amount}
onChange={(event) => setNewPayment((prev) => ({ ...prev, amount: event.target.value }))}
placeholder="Monto"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
type="date"
value={newPayment.paymentDate}
onChange={(event) => setNewPayment((prev) => ({ ...prev, paymentDate: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newPayment.concept}
onChange={(event) => setNewPayment((prev) => ({ ...prev, concept: event.target.value }))}
placeholder="Concepto"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<Button onClick={createPayment} disabled={isBusy}>
Guardar pago
</Button>
</div>
</Dialog>
{payments.length === 0 ? <p className="text-sm text-[#60718f]">Sin pagos registrados.</p> : null}
{payments.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#1d335a]">{item.contractTitle}</p>
<span className="text-sm font-semibold text-[#1d335a]">{formatCurrency(item.amount, item.currency)}</span>
</div>
<p className="mt-1 text-xs text-[#60718f]">{item.concept}</p>
<p className="mt-1 text-xs text-[#60718f]">
{formatDate(item.paymentDate)} - {item.status}
</p>
</article>
))}
</div>
),
},
]}
/>
</div>
);
}