1047 lines
40 KiB
TypeScript
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->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->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>
|
|
);
|
|
}
|