changes
This commit is contained in:
1
.claude/worktrees/modest-meninsky-d24f27
Submodule
1
.claude/worktrees/modest-meninsky-d24f27
Submodule
Submodule .claude/worktrees/modest-meninsky-d24f27 added at 65aaf9275e
1
.claude/worktrees/thirsty-mclean-9799f6
Submodule
1
.claude/worktrees/thirsty-mclean-9799f6
Submodule
Submodule .claude/worktrees/thirsty-mclean-9799f6 added at 65aaf9275e
273
Implementation fix.md
Normal file
273
Implementation fix.md
Normal file
@@ -0,0 +1,273 @@
|
||||
Implementation Goal
|
||||
Deliver a single, continuous evidence flow so users do not re-upload PDFs across M4->M5->M8->M9->M10, and always see the right document lineage.
|
||||
|
||||
Target User Flow (after changes)
|
||||
|
||||
User analyzes source docs in M4 from M3 or M5 context.
|
||||
User opens M5 and gets only relevant extracted requirements.
|
||||
User creates M8 contract from M5 and contract gets proposal PDF continuity automatically.
|
||||
User opens M9 from M8 with contractId preserved through diagnosis and case creation.
|
||||
User opens M10 and sees live dossier with file-level links, freshness state, and clear traceability.
|
||||
Phase Plan
|
||||
|
||||
Phase 1: Fix Critical Continuity Breaks
|
||||
Stop unrelated M4 history fallback in M5 extraction when proposal has no linked source.
|
||||
Persist and use contractId in legal diagnosis finalization.
|
||||
Replace M9 contract selector from “IDs from existing cases” to “actual contracts list”.
|
||||
Files: workflow extract API, M5 detail page, legal diagnosis start, legal diagnosis answer, M9 view, M9 page.
|
||||
Phase 2: M5->M8 Document Reuse (No Reupload)
|
||||
On M8 autocreate, auto-copy latest proposal PDF into ContractDocument.
|
||||
Extend M8 extraction endpoint to accept sourceProposalId without mandatory new file upload.
|
||||
Add “Use proposal PDF from M5” action in M8 upload tab.
|
||||
Files: M8 page, contract extract API, contracts view, contract storage, proposal storage.
|
||||
Phase 3: Complete Contract Document UX
|
||||
Wire existing /api/contracts/upload into M8 UI.
|
||||
Show contract documents list in M8 cards.
|
||||
Add delete endpoint for contract documents and surface actions in UI.
|
||||
Files: contracts upload API, contracts view, contracts API folder.
|
||||
Phase 4: M10 Live Dossier + File-Level Traceability
|
||||
Make dossier load strategy explicit (live default, snapshot optional).
|
||||
Add freshness metadata (generatedAt, isStale, source indicator).
|
||||
Extend dossier payload from counts to include document-level entries and link targets.
|
||||
Render proposals/contracts/legal sections with drill-down and links in M10.
|
||||
Files: audits server, audits types, expediente API, M10 view.
|
||||
Phase 5: Secure Download Endpoints
|
||||
Add proposal document download endpoint.
|
||||
Add contract document download endpoint.
|
||||
Enforce ownership checks and safe storage path resolution.
|
||||
Files: proposals API folder, contracts API folder, proposal delete route pattern.
|
||||
Phase 6: Continuity Safeguards and Observability
|
||||
Add structured logs for handoffs (sourceProposalId, contractId, history source used).
|
||||
Add continuity warnings in UI when source context is missing.
|
||||
Add “why this file” trace badges where extraction picks source docs.
|
||||
Files: M4 view, M5 workflow view, M8 view, M10 view.
|
||||
Phase 7: Tests and Release Gates
|
||||
Add integration tests for full continuity path M4->M5->M8->M9->M10.
|
||||
Add regression tests for “wrong history picked” and “diagnosis loses contractId”.
|
||||
Add API tests for download ownership and safe path rules.
|
||||
Keep existing npm test, npm run lint, npm run build as mandatory gate.
|
||||
Files: lib tests root, component tests root.
|
||||
Acceptance Criteria
|
||||
|
||||
No forced reupload from M5 to M8 when proposal PDF exists.
|
||||
Legal cases created via diagnosis stay linked to selected contract.
|
||||
M5 never uses unrelated M4 history for extraction.
|
||||
M10 shows live, fresh dossier state and file-level evidence traceability.
|
||||
Users can securely download proposal/contract evidence from module context.
|
||||
End-to-end continuity test passes for M4->M10.
|
||||
Execution Order (Critical Path)
|
||||
|
||||
Phase 1
|
||||
Phase 2
|
||||
Phase 4
|
||||
Phase 5
|
||||
Phase 3
|
||||
Phase 6
|
||||
Phase 7
|
||||
Estimated Delivery
|
||||
|
||||
Phase 1-2: 2-3 days
|
||||
Phase 3-5: 2-3 days
|
||||
Phase 6-7: 1-2 days
|
||||
Total: ~1 week of focused implementation
|
||||
If you want, I can start immediately with Phase 1 and push the exact code changes in that order.
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-09
|
||||
Phase 1 Status: Completed
|
||||
|
||||
Completed in Phase 1
|
||||
|
||||
- M5 extraction continuity guard: removed unrelated M4 history fallback when proposal has no linked `sourceLicitationId`.
|
||||
- M5 detail continuity guard: history context now loads only when tied to the same linked `sourceLicitationId`.
|
||||
- M9 diagnosis continuity: diagnosis start now validates and persists `contractId` in diagnosis context.
|
||||
- M9 diagnosis finalization continuity: legal case auto-created from diagnosis now reuses persisted `contractId` (with ownership validation).
|
||||
- M9 contract selector source: selector options now come from actual M8 contracts instead of contract IDs inferred from existing legal cases.
|
||||
|
||||
Validation Run
|
||||
|
||||
- `npm test`: passed (22 files, 61 tests).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 2: M5 -> M8 document reuse (no reupload).
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-09
|
||||
Phase 2 Status: Completed
|
||||
|
||||
Completed in Phase 2
|
||||
|
||||
- M8 autocreate continuity: when opening M8 from M5 (`autocreate=1`), the flow now auto-links the latest proposal PDF into `ContractDocument` (deduplicated).
|
||||
- M8 extraction endpoint continuity: `/api/contracts/extract` now accepts `sourceProposalId` without mandatory file upload.
|
||||
- M8 extraction source handling: route can analyze either uploaded PDF or proposal PDF; it preserves `sourceProposalId` on contract create/update.
|
||||
- M8 proposal PDF reuse UI: added explicit action in upload tab to "Usar PDF de propuesta de M5" with proposal selector and timestamped file labels.
|
||||
- Shared continuity utilities:
|
||||
- Safe proposal document read helper in proposal storage.
|
||||
- Proposal->contract continuity helper for latest PDF lookup and deduplicated linking.
|
||||
|
||||
Validation Run (Phase 2)
|
||||
|
||||
- `npm test`: passed (22 files, 61 tests).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 4: M10 live dossier + file-level traceability (per critical path order).
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-14
|
||||
Phase 4 Status: Completed
|
||||
|
||||
Completed in Phase 4
|
||||
|
||||
- Dossier load strategy is now explicit in M10:
|
||||
- API and UI support `live` (default) and `snapshot` (optional) loading.
|
||||
- M10 now shows requested strategy vs actual source used.
|
||||
- Freshness metadata added and surfaced:
|
||||
- `generatedAt`, `isStale`, `source`, `snapshotId`, `staleAfterMinutes`.
|
||||
- Snapshot generation returns synchronized freshness metadata.
|
||||
- Dossier payload upgraded from aggregate-only counts to file-level traceability:
|
||||
- M5 proposals now include `documentEntries` with links and trace labels.
|
||||
- M8 contracts now include `documentEntries` with links, kind, and lineage to source proposal when available.
|
||||
- M9 legal cases now include `documentEntries` with links and trace labels to legal context/contract continuity.
|
||||
- M10 UI drill-down completed:
|
||||
- Separate sections for proposals/contracts/legal with per-record links.
|
||||
- Per-file expandable trace blocks showing mime, size/date, trace reason, and actionable links.
|
||||
|
||||
Validation Run (Phase 4)
|
||||
|
||||
- `npm test`: passed (23 files, 67 tests).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 5: secure download endpoints for proposal and contract evidence.
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-14
|
||||
Phase 5 Status: Completed
|
||||
|
||||
Completed in Phase 5
|
||||
|
||||
- Added secure proposal evidence download endpoint:
|
||||
- `GET /api/proposals/[id]/documents/[documentId]`.
|
||||
- Enforces ownership (`proposalId` + `documentId` + `userId`) before file access.
|
||||
- Uses safe storage path guard and bounded file read helper.
|
||||
- Returns attachment headers with safe filename handling.
|
||||
- Added secure contract evidence download endpoint:
|
||||
- `GET /api/contracts/[id]/documents/[documentId]`.
|
||||
- Enforces ownership via contract relation (`contract.userId`).
|
||||
- Uses new safe contract document read helper with path traversal protection.
|
||||
- Returns attachment headers with safe filename handling.
|
||||
- Added safe contract storage read helper:
|
||||
- `readStoredContractDocumentFile(...)` with canonical path validation and size guard.
|
||||
- M10 file-level traceability now exposes direct secure download links for proposal and contract evidence from dossier context.
|
||||
|
||||
Validation Run (Phase 5)
|
||||
|
||||
- `npm test`: passed (23 files, 67 tests).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 3: complete contract document UX (wire upload UI, show docs in M8 cards, add delete endpoint/actions).
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-14
|
||||
Phase 3 Status: Completed
|
||||
|
||||
Completed in Phase 3
|
||||
|
||||
- M8 upload UX now wires existing `/api/contracts/upload`:
|
||||
- Added explicit "Adjuntar documento al contrato (sin IA)" flow in M8 upload tab.
|
||||
- Supports selecting contract target, document kind, and PDF file.
|
||||
- Contract document cards in M8 now include document list and actions:
|
||||
- File name, kind, size, and timestamp shown per document.
|
||||
- Direct download action per file.
|
||||
- Delete action per file.
|
||||
- Added contract document delete endpoint:
|
||||
- `DELETE /api/contracts/[id]/documents/[documentId]`.
|
||||
- Enforces ownership via `contract.userId`.
|
||||
- Removes both storage file and DB row with safe path handling.
|
||||
|
||||
Validation Run (Phase 3)
|
||||
|
||||
- `npm test`: passed (23 files, 67 tests).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 6: continuity safeguards and observability.
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-15
|
||||
Phase 6 Status: Completed
|
||||
|
||||
Completed in Phase 6
|
||||
|
||||
- Added structured continuity handoff logs across critical APIs:
|
||||
- `m4_to_m5_*` events in `/api/proposals/[id]/workflow/extract`.
|
||||
- `m5_to_m8_*` events in `/api/contracts/extract`.
|
||||
- `m8_to_m9_*` events in `/api/legal/diagnosis/start`.
|
||||
- `m9_*` continuity events in `/api/legal/diagnosis/answer`.
|
||||
- Added page-level observability for M5->M8 autocreate in `/gestion-contratos`:
|
||||
- Logs for autocreate request, proposal resolution, contract target resolution, proposal PDF lookup, and link outcomes.
|
||||
- Added continuity safeguards and operator-facing warnings in UI:
|
||||
- M4 (`normative-analysis-view`): warning when no linked source context.
|
||||
- M5 (`proposal-workflow-view`): warning when no M4 context for extraction.
|
||||
- M8 (`contracts-management-view`): warnings for missing proposal PDFs and contracts without `sourceProposalId`.
|
||||
- M10 (`preventive-dossier-view`): warnings derived from incomplete dossier component evidence.
|
||||
- Added explicit traceability badges (`Por que este archivo`) where file/source selection affects continuity in M4, M5, M8, and M10.
|
||||
|
||||
Validation Note (Phase 6)
|
||||
|
||||
- Continuity observability changes were carried into Phase 7 release-gate validation and passed together with final gates.
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Phase 7: tests and release gates.
|
||||
|
||||
Progress Update
|
||||
|
||||
Date: 2026-04-15
|
||||
Phase 7 Status: Completed
|
||||
|
||||
Completed in Phase 7
|
||||
|
||||
- Added integration continuity test for M4->M5->M8->M9->M10 dossier lineage:
|
||||
- `src/lib/audits/__tests__/dossier-continuity.integration.test.ts`.
|
||||
- Verifies proposal->contract->legal continuity links and evidence trace payload in M10.
|
||||
- Added regression test for "wrong history picked" continuity break:
|
||||
- `src/app/api/proposals/[id]/workflow/extract/route.test.ts`.
|
||||
- Verifies extraction is blocked when no linked M4 source and no uploaded PDF.
|
||||
- Added regression test for "diagnosis loses contractId":
|
||||
- `src/app/api/legal/diagnosis/answer/route.test.ts`.
|
||||
- Verifies finalization preserves persisted `contractId` and links the auto-created legal case to that contract.
|
||||
- Added API security tests for download ownership/path safety:
|
||||
- `src/app/api/proposals/[id]/documents/[documentId]/route.test.ts`.
|
||||
- `src/app/api/contracts/[id]/documents/[documentId]/route.test.ts`.
|
||||
- Verifies 404 on non-owned/missing document and 400 on invalid storage path.
|
||||
- Fixed JSX parsing safety in M8 continuity messaging (`M5->M8`) to keep lint/build green.
|
||||
|
||||
Validation Run (Phase 7)
|
||||
|
||||
- `npm test`: passed (28 files, 74 tests).
|
||||
- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors).
|
||||
- `npm run build`: passed (Next.js production build succeeded).
|
||||
|
||||
Next Planned Step
|
||||
|
||||
- Planned implementation phases (1-7) are complete. Ready for QA/UAT and deployment checklist.
|
||||
97
README.md
97
README.md
@@ -66,6 +66,66 @@ OPENAI_ACTA_TIMEOUT_MS=60000
|
||||
OPENAI_ACTA_MAX_CHARS=45000
|
||||
```
|
||||
|
||||
## AI Assist Modules (M1/M2/M3/M7/M10)
|
||||
|
||||
The platform now includes assist-only AI helpers for:
|
||||
|
||||
- `M1` Diagnostic suggestions per question (apply/dismiss).
|
||||
- `M2` Strategic insights and suggested field values (explicit apply only).
|
||||
- `M3` AI fit + blended score layered on deterministic recommendations.
|
||||
- `M7` Compliance playbook recommendations (no auto state/severity updates).
|
||||
- `M10` Auditor-style findings simulation from dossier + simulation data.
|
||||
|
||||
Deterministic scoring and compliance engines remain the source of truth.
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
OPENAI_SMART_MODEL=gpt-4.1
|
||||
OPENAI_SMART_FALLBACK_MODEL=gpt-4.1-mini
|
||||
OPENAI_SMART_TIMEOUT_MS=75000
|
||||
OPENAI_SMART_MAX_CHARS=55000
|
||||
|
||||
# Optional module overrides
|
||||
OPENAI_M1_MODEL=gpt-4.1
|
||||
OPENAI_M2_MODEL=gpt-4.1
|
||||
OPENAI_M3_MODEL=gpt-4.1
|
||||
OPENAI_M7_MODEL=gpt-4.1
|
||||
OPENAI_M10_MODEL=gpt-4.1
|
||||
```
|
||||
|
||||
Traceability:
|
||||
|
||||
- AI suggestions are persisted in `AiSuggestion` with request/response metadata.
|
||||
- Use `POST /api/ai/suggestions/{id}/decision` with `{ "decision": "accept" | "dismiss" }` to persist user action.
|
||||
|
||||
## Mercado Pago Checkout (Planes M2-M10)
|
||||
|
||||
Se agrego integracion de checkout para vender planes:
|
||||
|
||||
- `Plan 1` -> Modulos `2-4`
|
||||
- `Plan 2` -> Modulos `5-7`
|
||||
- `Plan 3` -> Modulos `8-10`
|
||||
|
||||
Variables requeridas:
|
||||
|
||||
```bash
|
||||
MP_ACCESS_TOKEN=TEST-... # token de prueba o produccion
|
||||
MP_API_BASE_URL=https://api.mercadopago.com
|
||||
MP_PLAN_DURATION_DAYS=30
|
||||
```
|
||||
|
||||
Flujo:
|
||||
|
||||
- `GET /api/payments/checkout?plan=plan-1|plan-2|plan-3` crea preferencia y redirige a Mercado Pago.
|
||||
- `GET /api/payments/checkout/return` procesa callback y regresa a `/dashboard`.
|
||||
- `POST /api/payments/mercadopago/webhook` sincroniza pagos aprobados.
|
||||
|
||||
DB:
|
||||
|
||||
- Nuevos modelos Prisma: `ModulePlanPurchase`, `ModulePlanSubscription`.
|
||||
- Ejecuta migraciones antes de usar el checkout en un entorno nuevo.
|
||||
|
||||
## Local CLI Script (PDF -> OCR/text -> AI)
|
||||
|
||||
Run:
|
||||
@@ -87,9 +147,17 @@ Add these vars to `.env`:
|
||||
```bash
|
||||
LICITAYA_API_KEY=your-licitaya-api-key
|
||||
LICITAYA_BASE_URL=https://<licitaya-base-url>
|
||||
LICITAYA_TEST_ENDPOINT=/tender/search?items=10&page=1
|
||||
LICITAYA_TEST_ENDPOINT=/tender/SCRZJ
|
||||
LICITAYA_ACCEPT=application/json
|
||||
LICITAYA_TIMEOUT_MS=20000
|
||||
LICITAYA_ALLOW_EMPTY_SEARCH=true
|
||||
LICITAYA_ENABLED=true
|
||||
LICITAYA_ITEMS_PER_PAGE=50
|
||||
LICITAYA_MAX_PAGES_PER_RUN=3
|
||||
LICITAYA_SYNC_INTERVAL_HOURS=12
|
||||
LICITAYA_DATE_OFFSET_DAYS=1
|
||||
LICITAYA_DATE_FALLBACK_WINDOW_DAYS=3
|
||||
LICITAYA_FALLBACK_TENDER_IDS=SCRZJ
|
||||
```
|
||||
|
||||
Run the connection test:
|
||||
@@ -132,4 +200,29 @@ Notes:
|
||||
|
||||
- The script sends your key in header `X-API-KEY`.
|
||||
- It prints status code + response preview.
|
||||
- A non-2xx response exits with code `1` (useful for CI checks).
|
||||
- For `/tender/search`, you can allow an empty 404 as connectivity success with `LICITAYA_ALLOW_EMPTY_SEARCH=true` or `--allow-empty-search`.
|
||||
- A non-2xx response exits with code `1` unless empty-search mode is allowed.
|
||||
|
||||
## Compliance Cron + Persistence (M7)
|
||||
|
||||
This project now persists official-regulations verification state and suggestions in Postgres.
|
||||
|
||||
Apply migration and regenerate Prisma client:
|
||||
|
||||
```bash
|
||||
npm run prisma:migrate
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
Scheduler endpoints:
|
||||
|
||||
- `GET/POST /api/cron/licitations-sync` (includes periodic regulations verification by default)
|
||||
- `GET/POST /api/cron/regulations-verify` (regulations-only run)
|
||||
|
||||
Accepted auth for cron routes:
|
||||
|
||||
- Header `x-sync-token: $LICITATIONS_SYNC_TOKEN`
|
||||
- Header `Authorization: Bearer $CRON_SECRET` (Vercel cron compatible)
|
||||
- Query param `?token=$LICITATIONS_SYNC_TOKEN` (fallback)
|
||||
|
||||
Vercel schedules are included in [`vercel.json`](./vercel.json).
|
||||
|
||||
106
ai_integration.md
Normal file
106
ai_integration.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# AI Integration Plan for Modules 1, 2, 3, 7, and 10
|
||||
|
||||
## Summary
|
||||
- Goal: add AI assistance to M1, M2, M3, M7, and M10 while keeping deterministic scoring/compliance logic as the source of truth.
|
||||
- Product mode selected: **Assist-only** (AI suggests; user explicitly accepts/applies).
|
||||
- Model strategy selected: **Higher quality** (primary `gpt-4.1`, fallback `gpt-4.1-mini`).
|
||||
- Persistence selected: **Structured traceability** (store request/response metadata, model, usage, warnings, and accept/dismiss events).
|
||||
|
||||
## Core Architecture Changes
|
||||
- Add a shared AI service layer used by all 5 modules:
|
||||
- `callOpenAiJsonSchema()` and `callOpenAiJsonObjectFallback()`.
|
||||
- shared timeout/retry handling, prompt versioning, truncation guard, and safe JSON parsing.
|
||||
- standardized output envelope: `engine`, `model`, `usage`, `warnings`, `confidence`.
|
||||
- Add a shared persistence model for AI traces:
|
||||
- New Prisma model `AiSuggestion` (or equivalent name), fields:
|
||||
- `id`, `userId`, `moduleKey`, `featureKey`, `subjectType`, `subjectId`.
|
||||
- `inputHash`, `requestJson`, `responseJson`, `confidence`.
|
||||
- `engine`, `model`, `usageJson`, `warningsJson`, `promptVersion`.
|
||||
- `status` (`GENERATED | ACCEPTED | DISMISSED | EXPIRED`), `actedAt`, `createdAt`, `updatedAt`.
|
||||
- Purpose:
|
||||
- deduplicate repeated requests by `inputHash`.
|
||||
- preserve auditability and user decisions.
|
||||
- Add shared decision endpoint:
|
||||
- `POST /api/ai/suggestions/{id}/decision` with `{ decision: "accept" | "dismiss" }`.
|
||||
- Keep deterministic engines untouched and authoritative:
|
||||
- M1 scoring, M2 scoring, M3 deterministic match score, M7 rule alerts, M10 scoring remain unchanged.
|
||||
|
||||
## Public API and Type Additions
|
||||
- `POST /api/diagnostic/ai/suggestions` (M1):
|
||||
- Input: `moduleKey`, optional `questionId`.
|
||||
- Output: list of suggestions with `questionId`, `suggestedAnswerOptionId`, `rationale`, `missingEvidence`, `confidence`, `suggestionId`.
|
||||
- `POST /api/strategic-diagnostic/ai/insights` (M2):
|
||||
- Input: current strategic form snapshot + evidence metadata.
|
||||
- Output: `sectionGaps`, `priorityActions`, `suggestedEvidence`, `suggestedFieldValues`, `confidence`, `suggestionId`.
|
||||
- `GET /api/licitations/ai/recommendations` (M3):
|
||||
- Input: optional query (`top`, `profileId`), based on deterministic pre-ranked items.
|
||||
- Output: deterministic score + AI score + blended score + `aiReasons`, `aiRisks`, `nextStep`, `suggestionId`.
|
||||
- `POST /api/compliance/m7/ai/playbook` (M7):
|
||||
- Input: current M7 dataset.
|
||||
- Output: `predictedIncidents`, `priorityOrder`, `preventiveActions`, `escalationAdvice`, `confidence`, `suggestionId`.
|
||||
- `POST /api/audits/ai/findings` (M10):
|
||||
- Input: selected simulation + current institutional dossier.
|
||||
- Output: `auditorLikelyFindings`, `missingEvidence`, `topRisks`, `remediationPlan`, `confidence`, `suggestionId`.
|
||||
- Type updates:
|
||||
- Extend view types to include optional AI sections per module (`aiSuggestion`, `aiInsights`, `aiFit`, `aiPlaybook`, `aiFindings`) with strict nullable typing.
|
||||
- Add common `AiUsage`, `AiWarning`, `AiSuggestionStatus`, `AiDecision`.
|
||||
|
||||
## Module-by-Module Behavior
|
||||
- M1:
|
||||
- AI infers likely answers for unanswered questions based on answered responses and evidence notes.
|
||||
- UI per question: “Sugerencia IA” card with `Apply` and `Dismiss`.
|
||||
- Applying uses existing response save route so scoring pipeline is unchanged.
|
||||
- M2:
|
||||
- AI analyzes strategic data + evidence counts to propose prioritized actions and missing evidence by section.
|
||||
- UI in each section + results tab: “Plan sugerido por IA”.
|
||||
- Suggested field values are shown as explicit apply actions, never auto-written.
|
||||
- M3:
|
||||
- Keep deterministic scoring as base.
|
||||
- AI re-ranks only top deterministic candidates to control latency/cost.
|
||||
- UI shows three values: deterministic score, AI fit, blended score; reasons become semantic and more specific.
|
||||
- M7:
|
||||
- AI creates a prioritized compliance playbook from existing alerts/deadlines/checklist.
|
||||
- UI adds “Plan IA” tab with incident predictions + actions with owner suggestion and target date.
|
||||
- No severity/status changes are auto-applied.
|
||||
- M10:
|
||||
- AI simulates auditor perspective using latest simulation and dossier.
|
||||
- UI adds “Dictamen IA” with likely findings, missing evidence, and remediation order.
|
||||
- Deterministic section scores remain unchanged and visible alongside AI narrative.
|
||||
|
||||
## Test Plan
|
||||
- Unit tests:
|
||||
- AI parser/normalizer for each module schema.
|
||||
- prompt truncation + fallback behavior.
|
||||
- suggestion persistence lifecycle (`GENERATED -> ACCEPTED/DISMISSED`).
|
||||
- Route tests:
|
||||
- auth, validation, malformed JSON handling.
|
||||
- OpenAI failure paths return structured fallback/empty-assist safely.
|
||||
- Integration tests:
|
||||
- M1 apply suggestion updates response but does not break scoring calculations.
|
||||
- M2/M3/M7/M10 AI outputs render without changing deterministic KPIs/scores unless user explicitly applies where applicable.
|
||||
- Regression tests:
|
||||
- existing deterministic test suites for M1/M2/M3/M7/M10 continue passing unchanged.
|
||||
- Acceptance scenarios:
|
||||
- each module can produce at least one AI suggestion with trace metadata.
|
||||
- user can accept/dismiss and decision is persisted.
|
||||
- pages remain functional when AI is unavailable.
|
||||
|
||||
## Environment and Rollout Defaults
|
||||
- New env vars:
|
||||
- `OPENAI_SMART_MODEL=gpt-4.1`
|
||||
- `OPENAI_SMART_FALLBACK_MODEL=gpt-4.1-mini`
|
||||
- `OPENAI_SMART_TIMEOUT_MS=75000`
|
||||
- `OPENAI_SMART_MAX_CHARS=55000`
|
||||
- optional per-module overrides (`OPENAI_M1_MODEL`, `OPENAI_M2_MODEL`, etc.).
|
||||
- Rollout:
|
||||
- Phase 1: backend + trace model + M3 and M10 (highest visible value).
|
||||
- Phase 2: M1 and M2 assist flows.
|
||||
- Phase 3: M7 predictive playbook tuning.
|
||||
- Documentation updates:
|
||||
- add a new section in README for “AI Assist Modules (M1/M2/M3/M7/M10)” and env variables.
|
||||
|
||||
## Assumptions
|
||||
- Current auth gating behavior remains unchanged in this scope.
|
||||
- AI output language is Spanish for end-user text.
|
||||
- No auto-application beyond explicit user action.
|
||||
- Existing AI-enabled modules (M4/M5/M6/M8/M9) are not refactored in this effort.
|
||||
32
docs/ui-parity-checklist.md
Normal file
32
docs/ui-parity-checklist.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# UI Parity Checklist (Modules 3-5)
|
||||
|
||||
## Baseline
|
||||
- Font stack: `Manrope` (body), `Playfair Display` (display headings).
|
||||
- Primary colors: navy action buttons, slate neutrals, green success accents.
|
||||
- Card radius and border hierarchy aligned to reference module screenshots.
|
||||
|
||||
## Module 3
|
||||
- Hero title/subtitle and sync CTA placement.
|
||||
- Guide panel + KPI cards + "ultimo analisis" row.
|
||||
- Search/filter bars with geo filters.
|
||||
- Opportunity cards (badges, compatibility %, progress line, details toggle).
|
||||
- Bottom handoff CTA to Module 4.
|
||||
|
||||
## Module 4
|
||||
- Hero title/subtitle.
|
||||
- Left column: guide + drag/drop upload + metadata fields.
|
||||
- Right column: empty-state and selected-analysis state.
|
||||
- Bottom navigation block to Module 3 / Module 5 / Dashboard.
|
||||
|
||||
## Module 5
|
||||
- Hero title/subtitle.
|
||||
- Search + "Nueva Propuesta" controls.
|
||||
- Empty state with primary CTA.
|
||||
- Populated cards with edit fields, status, drag/drop docs, document list.
|
||||
- Forward nav to M6 placeholder.
|
||||
|
||||
## Topbar
|
||||
- Authenticated module pages use minimal module-flow header.
|
||||
- Back link visible in module mode.
|
||||
- Optional Manual, plan badge, and concise actions only.
|
||||
- No dense authenticated global nav links.
|
||||
463
final_implementation.md
Normal file
463
final_implementation.md
Normal file
@@ -0,0 +1,463 @@
|
||||
# Final Implementation Plan - Modulos 8, 9 y 10
|
||||
|
||||
## 1. Objetivo
|
||||
Implementar M08, M09 y M10 como una cadena operativa completa:
|
||||
|
||||
- M08 Gestion Estrategica de Contratos: registro de contratos, entregables, pagos y carga inteligente de PDF.
|
||||
- M09 Proteccion Legal: diagnostico guiado, gestion de casos, ruta de escalada, generador de escritos y directorio.
|
||||
- M10 Expediente Preventivo: simulador de auditoria y expediente institucional centralizado.
|
||||
|
||||
El alcance sigue exactamente la UX mostrada en las referencias y se integra con M5 y M7 ya existentes.
|
||||
|
||||
## 2. Principios de implementacion
|
||||
- Reusar patrones actuales: App Router, Prisma, componentes estilo M7, endpoints administrativos autenticados.
|
||||
- Mantener trazabilidad completa: cada caso legal y cada auditoria debe poder rastrear contrato, entregable, pago y evidencia origen.
|
||||
- Diseñar para escalamiento nacional: iniciar con seed federal + NL y permitir extender por estado/municipio.
|
||||
- Regla conservadora legal: donde no haya certeza normativa, marcar "requiere validacion legal".
|
||||
|
||||
## 3. M08 - Gestion Estrategica de Contratos
|
||||
|
||||
### 3.1 Ruta y UI
|
||||
- Nueva ruta: `/gestion-contratos`.
|
||||
- Header de flujo:
|
||||
- back: `M7: Alertas`
|
||||
- next: `M9: Legal`
|
||||
- KPIs:
|
||||
- Contratos activos
|
||||
- Total cobrado
|
||||
- Entregables pendientes
|
||||
- Entregables vencidos
|
||||
- Tabs:
|
||||
- Subir Contrato
|
||||
- Contratos
|
||||
- Entregables
|
||||
- Pagos
|
||||
|
||||
### 3.2 Dominio y modelo de datos
|
||||
Agregar en Prisma:
|
||||
|
||||
- `ContractRecord`
|
||||
- `id, userId, sourceProposalId?`
|
||||
- `title, counterpartyEntity, contractNumber, contractType`
|
||||
- `startDate, endDate, totalAmount, currency`
|
||||
- `status` (`ACTIVE|COMPLETED|PAUSED|CANCELLED`)
|
||||
- `description, createdAt, updatedAt`
|
||||
- `ContractDeliverable`
|
||||
- `id, contractId, title, dueDate, amountLinked?`
|
||||
- `status` (`PENDING|DELIVERED|APPROVED|REJECTED|OVERDUE`)
|
||||
- `deliveredAt?, approvedAt?, notes`
|
||||
- `ContractPayment`
|
||||
- `id, contractId, amount, paymentDate, invoiceNumber?, concept`
|
||||
- `status` (`REGISTERED|CONFIRMED|DISPUTED`)
|
||||
- `ContractDocument`
|
||||
- `id, contractId, fileName, filePath, mimeType, sizeBytes, checksumSha256`
|
||||
- `kind` (`SIGNED_CONTRACT|ADDENDUM|DELIVERABLE_EVIDENCE|PAYMENT_EVIDENCE|OTHER`)
|
||||
- `ContractExtractionHistory`
|
||||
- `id, contractId?, userId, engine, model, resultJson, warningsJson, analyzedAt`
|
||||
|
||||
Indices:
|
||||
- `ContractRecord(userId,status,endDate)`
|
||||
- `ContractDeliverable(contractId,status,dueDate)`
|
||||
- `ContractPayment(contractId,paymentDate)`
|
||||
|
||||
### 3.3 APIs
|
||||
- `GET /api/contracts` list + filtros.
|
||||
- `POST /api/contracts` crear contrato manual (modal Nuevo Contrato).
|
||||
- `PATCH /api/contracts/:id` editar estado y datos clave.
|
||||
- `POST /api/contracts/upload` subir PDF firmado.
|
||||
- `POST /api/contracts/extract` OCR + IA y crear/actualizar contrato.
|
||||
- `GET /api/contracts/kpis` dataset de M08.
|
||||
- `POST /api/contracts/:id/deliverables` crear entregable (modal).
|
||||
- `PATCH /api/deliverables/:id` cambiar estado.
|
||||
- `POST /api/contracts/:id/payments` registrar pago (modal).
|
||||
- `GET /api/contracts/:id/payments` historial.
|
||||
|
||||
### 3.4 IA para "Subir Contrato"
|
||||
Reusar pipeline de PDF existente:
|
||||
- `analyzePdf` para texto/OCR.
|
||||
- Nuevo extractor IA `extractContractWithAi`:
|
||||
- campos: titulo, entidad contratante, numero, tipo, fechas, monto total.
|
||||
- lista de entregables detectados.
|
||||
- hitos de pago detectados.
|
||||
- clausulas de riesgo contractual (para alimentar M9).
|
||||
- Guardar trazabilidad en `ContractExtractionHistory`.
|
||||
|
||||
### 3.5 Reglas funcionales
|
||||
- Contrato activo: `status=ACTIVE` y fecha fin no vencida (o sin fecha fin).
|
||||
- Entregable vencido: `status=PENDING` y `dueDate < now`.
|
||||
- Total cobrado: sumatoria pagos confirmados.
|
||||
- Barra de avance por contrato: `entregables completados / entregables totales`.
|
||||
|
||||
## 4. M09 - Proteccion Legal
|
||||
|
||||
### 4.1 Ruta y UI
|
||||
- Nueva ruta: `/proteccion-legal`.
|
||||
- Header de flujo:
|
||||
- back: `M8: Contratos`
|
||||
- next: `M10: Auditorias`
|
||||
- KPIs:
|
||||
- Casos abiertos
|
||||
- Alta severidad
|
||||
- Monto en riesgo
|
||||
- Casos resueltos
|
||||
- Tabs:
|
||||
- Diagnostico
|
||||
- Casos
|
||||
- Escalada
|
||||
- Escritos
|
||||
- Directorio
|
||||
|
||||
### 4.2 Dominio y modelo de datos
|
||||
Agregar en Prisma:
|
||||
|
||||
- `LegalCase`
|
||||
- `id, userId, contractId?`
|
||||
- `caseType` (`CONTRACT_BREACH|PAYMENT_RETENTION|UNJUST_SANCTION|CONTRACT_DISPUTE`)
|
||||
- `severity` (`LOW|MEDIUM|HIGH`)
|
||||
- `counterparty, description`
|
||||
- `amountAtRisk`
|
||||
- `status` (`OPEN|IN_PROGRESS|ESCALATED|RESOLVED|CLOSED`)
|
||||
- `openedAt, resolvedAt?`
|
||||
- `LegalDiagnosis`
|
||||
- `id, userId, legalCaseId?`
|
||||
- `stepIndex, totalSteps, answersJson, recommendedRouteJson`
|
||||
- `LegalEscalationStepLog`
|
||||
- `id, legalCaseId, routeStepKey, completedAt?, notes`
|
||||
- `LegalDocument`
|
||||
- `id, legalCaseId?, userId, templateKey?, aiGenerated`
|
||||
- `title, content, createdAt`
|
||||
- `LegalDirectoryEntity`
|
||||
- `id, jurisdictionLevel, name, scopeTagsJson`
|
||||
- `websiteUrl?, phone?, email?, stateCode?, municipalityCode?`
|
||||
- `isActive`
|
||||
|
||||
### 4.3 APIs
|
||||
- `GET /api/legal/kpis`
|
||||
- `POST /api/legal/diagnosis/start`
|
||||
- `POST /api/legal/diagnosis/answer`
|
||||
- `POST /api/legal/cases`
|
||||
- `GET /api/legal/cases`
|
||||
- `PATCH /api/legal/cases/:id` (resolver/escalar)
|
||||
- `GET /api/legal/escalation/:caseId`
|
||||
- `POST /api/legal/documents/generate` (IA)
|
||||
- `GET /api/legal/templates`
|
||||
- `GET /api/legal/directory`
|
||||
|
||||
### 4.4 Motor de diagnostico
|
||||
Wizard de 4 pasos como referencia:
|
||||
1. Tipo de incumplimiento.
|
||||
2. Estado procesal y evidencia disponible.
|
||||
3. Impacto economico y urgencia.
|
||||
4. Objetivo legal (pago, correccion, impugnacion, mediacion).
|
||||
|
||||
Salida:
|
||||
- severidad sugerida
|
||||
- tipo de caso sugerido
|
||||
- ruta escalada sugerida
|
||||
- lista de escritos recomendados
|
||||
|
||||
### 4.5 Ruta Escalada (tab Escalada)
|
||||
Catalogo base de pasos:
|
||||
1. Requerimiento formal
|
||||
2. Conciliacion administrativa
|
||||
3. Queja ante OIC
|
||||
4. Inconformidad ante SFP/Organo estatal
|
||||
5. Via jurisdiccional
|
||||
|
||||
Cada paso incluye:
|
||||
- ventana estimada de tiempo
|
||||
- checklist de acciones
|
||||
- bandera de "requiere abogado"
|
||||
- dependencias del paso previo
|
||||
|
||||
### 4.6 Escritos (IA + plantillas)
|
||||
- Selector de tipo de caso y severidad.
|
||||
- Inputs: contraparte, empresa, monto, descripcion.
|
||||
- Generador IA produce borrador estructurado y editable.
|
||||
- Plantillas predefinidas por categoria (como en referencia):
|
||||
- General: Acta entrega-recepcion.
|
||||
- Incumplimiento: Carta de inconformidad.
|
||||
- Retencion de pagos: Solicitud de liberacion.
|
||||
- Sanciones: Recurso de inconformidad.
|
||||
- Guardar documento y boton copiar/exportar.
|
||||
|
||||
### 4.7 Directorio
|
||||
Seed inicial federal + estatal:
|
||||
- SFP, TFJA, ASF, OIC, CompraNet, sistemas anticorrupcion estatales, etc.
|
||||
- Filtros por jurisdiccion y tipo de autoridad.
|
||||
|
||||
## 5. M10 - Expediente Preventivo
|
||||
|
||||
### 5.1 Ruta y UI
|
||||
- Nueva ruta: `/expediente-preventivo`.
|
||||
- Header de flujo:
|
||||
- back: `M9: Legal`
|
||||
- KPIs:
|
||||
- Auditorias completadas
|
||||
- Ultima calificacion
|
||||
- Contratos registrados (desde M08)
|
||||
- PDFs resguardados (M08 + M5)
|
||||
- Tabs:
|
||||
- Simulador
|
||||
- Expediente
|
||||
|
||||
### 5.2 Dominio y modelo de datos
|
||||
Agregar en Prisma:
|
||||
|
||||
- `AuditSimulation`
|
||||
- `id, userId, name, auditType`
|
||||
- `status` (`DRAFT|COMPLETED`)
|
||||
- `overallScore, completedAt?`
|
||||
- `AuditSimulationSection`
|
||||
- `id, simulationId, key`
|
||||
- `score, status` (`READY|WARNING|CRITICAL`)
|
||||
- `findingsJson, recommendationsJson`
|
||||
- `AuditChecklistResponse`
|
||||
- `id, simulationId, questionKey, answer, evidenceRefsJson`
|
||||
- `InstitutionalDossierSnapshot`
|
||||
- `id, userId, generatedAt, payloadJson`
|
||||
|
||||
Secciones del simulador v1:
|
||||
- Cumplimiento fiscal
|
||||
- Cumplimiento laboral
|
||||
- Documentacion legal
|
||||
- Control operativo
|
||||
- Transparencia financiera
|
||||
|
||||
### 5.3 APIs
|
||||
- `POST /api/audits/simulations`
|
||||
- `GET /api/audits/simulations`
|
||||
- `POST /api/audits/simulations/:id/score`
|
||||
- `GET /api/audits/simulations/:id`
|
||||
- `GET /api/audits/expediente`
|
||||
- `POST /api/audits/expediente/refresh`
|
||||
|
||||
### 5.4 Logica del expediente
|
||||
Consolidar automaticamente:
|
||||
- M5: propuestas, workflow, documentos.
|
||||
- M8: contratos, entregables, pagos, contratos PDF.
|
||||
- M9: casos, escritos, evidencia de escalada.
|
||||
- M7: alertas activas de cumplimiento para semaforo preventivo.
|
||||
|
||||
Resultado:
|
||||
- vista de historial institucional lista para auditoria.
|
||||
- estado por componente (completo/incompleto).
|
||||
|
||||
## 6. Integraciones entre modulos
|
||||
- M5 -> M8: crear contrato desde propuesta adjudicada.
|
||||
- M8 -> M9: boton "abrir caso legal" desde contrato/entregable/pago.
|
||||
- M8 -> M10: contratos y PDFs alimentan expediente.
|
||||
- M9 -> M10: casos, escritos y escalada alimentan expediente.
|
||||
- M7 <-> M8/M9: alertas por entregables vencidos, pagos disputados, casos alta severidad.
|
||||
|
||||
## 7. Tipos de dominio (frontend/backend)
|
||||
Crear `src/lib/contracts/types.ts`, `src/lib/legal/types.ts`, `src/lib/audits/types.ts` con:
|
||||
- snapshots KPI por modulo
|
||||
- enums de estado
|
||||
- payloads de formularios (modales)
|
||||
- DTOs para tabs
|
||||
- contratos de IA (input/output) para extractor de contrato y generador de escritos
|
||||
|
||||
## 8. Plan de implementacion por fases
|
||||
|
||||
### Fase A (base de datos + navegacion)
|
||||
- Prisma models, migraciones, seeds.
|
||||
- Rutas vacias M08/M09/M10 con header y tabs.
|
||||
- Linkear dashboard (Plan 3) a rutas reales.
|
||||
|
||||
### Fase B (M08 operativo)
|
||||
- CRUD contratos + entregables + pagos.
|
||||
- KPIs y tabs funcionales.
|
||||
- Upload PDF + extraccion IA + guardado en expediente.
|
||||
|
||||
### Fase C (M09 operativo)
|
||||
- Diagnostico wizard y creacion de casos.
|
||||
- Registro/seguimiento de casos.
|
||||
- Escalada guiada con checklist por paso.
|
||||
- Generador IA de escritos + plantillas + copiar/exportar.
|
||||
- Directorio con seed inicial.
|
||||
|
||||
### Fase D (M10 operativo)
|
||||
- Simulador de auditoria con scoring por seccion.
|
||||
- Expediente centralizado con agregacion M5/M8/M9.
|
||||
- KPIs finales y acciones correctivas sugeridas.
|
||||
|
||||
### Fase E (hardening)
|
||||
- permisos por plan, auditoria de cambios, logs de errores IA.
|
||||
- observabilidad de pipelines OCR/IA.
|
||||
- refinamiento de UX responsive y accesibilidad.
|
||||
|
||||
## 9. Plan de pruebas
|
||||
|
||||
### Unit
|
||||
- Formulas KPI M08/M09/M10.
|
||||
- Reglas de estado (vencido, resuelto, escalado, activo).
|
||||
- Scoring del simulador.
|
||||
- Seleccion de ruta legal por diagnostico.
|
||||
|
||||
### Integration
|
||||
- Flujo completo: contrato (M08) -> caso (M09) -> expediente (M10).
|
||||
- Extraccion IA de contrato -> creacion de entregables/pagos.
|
||||
- Generacion de escrito IA + guardado en caso.
|
||||
|
||||
### UI/E2E
|
||||
- Paridad visual de tabs y KPI cards con referencias.
|
||||
- Modales: Nuevo Contrato, Nuevo Entregable, Registrar Pago.
|
||||
- Estados vacios y poblados.
|
||||
- Responsive movil/escritorio.
|
||||
|
||||
## 10. Criterios de aceptacion
|
||||
- M08 permite crear contrato, registrar entregable y pago, y ver KPIs actualizados en tiempo real.
|
||||
- M09 permite abrir caso desde diagnostico, escalarlo por pasos, y generar escritos IA reutilizables.
|
||||
- M10 muestra simulaciones con score y expediente consolidado de evidencia documental.
|
||||
- Navegacion lineal operativa M7 -> M8 -> M9 -> M10.
|
||||
- Build, test y lint sin errores bloqueantes.
|
||||
|
||||
## 11. Defaults V1
|
||||
- Sin firma digital de escritos dentro del sistema (solo generacion y exportacion).
|
||||
- Sin integracion externa de tribunales/SFP para envio automatico (solo preparacion guiada).
|
||||
- Directorio legal inicial federal + NL, extensible por seed.
|
||||
- Clasificacion de riesgo legal conservadora si faltan evidencias.
|
||||
|
||||
## 12. Master Checklist de Implementacion
|
||||
|
||||
### 12.1 Preparacion
|
||||
- [ ] Confirmar alcance funcional final con producto (M08, M09, M10 + integraciones).
|
||||
- [ ] Validar naming final de rutas: `/gestion-contratos`, `/proteccion-legal`, `/expediente-preventivo`.
|
||||
- [ ] Confirmar permisos por plan (Plan 3) y reglas de acceso admin/pago.
|
||||
|
||||
### 12.2 Fase A - Data + Navegacion Base
|
||||
- [ ] Crear modelos Prisma M08/M09/M10.
|
||||
- [ ] Crear enums Prisma para estados de contratos, casos y auditorias.
|
||||
- [ ] Crear migraciones SQL + seeds iniciales (directorio legal, catalogos base).
|
||||
- [ ] Ejecutar `npm run prisma:generate`.
|
||||
- [ ] Crear paginas base de rutas M08, M09 y M10 con layout y header de flujo.
|
||||
- [ ] Conectar dashboard (tarjetas M08/M09/M10) a rutas reales.
|
||||
|
||||
### 12.3 Fase B - M08 Operativo
|
||||
- [ ] Implementar dominio `contracts` (`types.ts`, validadores, mappers).
|
||||
- [ ] Implementar endpoints CRUD de contratos.
|
||||
- [ ] Implementar endpoints de entregables y pagos.
|
||||
- [ ] Implementar endpoint KPI de M08.
|
||||
- [ ] Implementar tab Subir Contrato (upload PDF + persistencia de archivo).
|
||||
- [ ] Implementar extraccion IA de contrato y guardado de `ContractExtractionHistory`.
|
||||
- [ ] Implementar UI tabs M08 (Subir Contrato, Contratos, Entregables, Pagos).
|
||||
- [ ] Implementar modales: Nuevo Contrato, Nuevo Entregable, Registrar Pago.
|
||||
- [ ] Implementar reglas de negocio: activo, vencido, total cobrado, progreso.
|
||||
|
||||
### 12.4 Fase C - M09 Operativo
|
||||
- [ ] Implementar dominio `legal` (`types.ts`, enums, validaciones).
|
||||
- [ ] Implementar wizard Diagnostico (4 pasos) y recomendacion de ruta.
|
||||
- [ ] Implementar CRUD de casos legales.
|
||||
- [ ] Implementar tab Casos con acciones de resolver/escalar.
|
||||
- [ ] Implementar tab Escalada (pasos, checklist, estatus por caso).
|
||||
- [ ] Implementar generador de escritos IA y persistencia.
|
||||
- [ ] Implementar repositorio de plantillas predefinidas y accion copiar/exportar.
|
||||
- [ ] Implementar tab Directorio con filtros de jurisdiccion/autoridad.
|
||||
|
||||
### 12.5 Fase D - M10 Operativo
|
||||
- [ ] Implementar dominio `audits` (`types.ts`, scoring, snapshots).
|
||||
- [ ] Implementar CRUD de simulaciones de auditoria.
|
||||
- [ ] Implementar motor de scoring por seccion.
|
||||
- [ ] Implementar tab Simulador con historial y detalle de resultados.
|
||||
- [ ] Implementar agregador de expediente institucional (M5 + M8 + M9 + M7).
|
||||
- [ ] Implementar tab Expediente con estados vacios/poblados y trazabilidad.
|
||||
- [ ] Implementar KPIs de M10.
|
||||
|
||||
### 12.6 Fase E - Hardening
|
||||
- [ ] Reglas de autorizacion por plan en rutas y APIs.
|
||||
- [ ] Auditoria de cambios (logs funcionales por entidad critica).
|
||||
- [ ] Manejo robusto de errores IA/OCR con mensajes accionables.
|
||||
- [ ] Validacion de performance en listados y agregadores (paginacion/indices).
|
||||
- [ ] Reforzar accesibilidad (labels, focus, teclado, contraste).
|
||||
|
||||
## 13. Task Backlog (Orden de Ejecucion)
|
||||
|
||||
### 13.1 Arquitectura y Persistencia
|
||||
1. [ ] Crear schema Prisma para M08 (contratos, entregables, pagos, documentos, extraccion).
|
||||
2. [ ] Crear schema Prisma para M09 (casos, diagnostico, escalada, documentos, directorio).
|
||||
3. [ ] Crear schema Prisma para M10 (simulaciones, respuestas, snapshots).
|
||||
4. [ ] Agregar indices y constraints de integridad.
|
||||
5. [ ] Generar y revisar migraciones.
|
||||
6. [ ] Ejecutar seeds base (directorio legal + catalogos).
|
||||
|
||||
### 13.2 Backend APIs
|
||||
1. [ ] Crear `src/app/api/contracts/*`.
|
||||
2. [ ] Crear `src/app/api/legal/*`.
|
||||
3. [ ] Crear `src/app/api/audits/*`.
|
||||
4. [ ] Implementar servicios en `src/lib/contracts`, `src/lib/legal`, `src/lib/audits`.
|
||||
5. [ ] Agregar validaciones de entrada/salida y manejo de errores uniforme.
|
||||
|
||||
### 13.3 Frontend M08
|
||||
1. [ ] Crear pantalla principal M08 con 4 KPIs y tabs.
|
||||
2. [ ] Implementar tab Contratos (cards, progreso, estado).
|
||||
3. [ ] Implementar tab Entregables (pendiente/vencido/completado).
|
||||
4. [ ] Implementar tab Pagos (registro e historial).
|
||||
5. [ ] Implementar tab Subir Contrato + drag/drop + estado de analisis IA.
|
||||
6. [ ] Conectar modales a APIs y refresco de dataset.
|
||||
|
||||
### 13.4 Frontend M09
|
||||
1. [ ] Crear pantalla principal M09 con KPIs y tabs.
|
||||
2. [ ] Implementar wizard Diagnostico con barra de progreso.
|
||||
3. [ ] Implementar tab Casos con filtros por severidad/estado.
|
||||
4. [ ] Implementar tab Escalada con timeline de pasos.
|
||||
5. [ ] Implementar tab Escritos (formulario IA + plantillas).
|
||||
6. [ ] Implementar tab Directorio (cards de entidades + acciones contacto).
|
||||
|
||||
### 13.5 Frontend M10
|
||||
1. [ ] Crear pantalla principal M10 con KPIs y tabs.
|
||||
2. [ ] Implementar tab Simulador (nueva simulacion, scoring y resultados).
|
||||
3. [ ] Implementar tab Expediente (contratos, PDFs, propuestas, casos, escritos).
|
||||
4. [ ] Implementar navegacion back/next M9 <-> M10 y Dashboard.
|
||||
|
||||
### 13.6 Integraciones Cross-Module
|
||||
1. [ ] Habilitar handoff M5 -> M8 para crear contrato desde propuesta adjudicada.
|
||||
2. [ ] Habilitar handoff M8 -> M9 para abrir caso legal contextual.
|
||||
3. [ ] Habilitar agregacion M8 + M9 en M10.
|
||||
4. [ ] Publicar alertas M8/M9 relevantes para M7 (vencimientos y riesgo legal).
|
||||
|
||||
## 14. Master Test Plan
|
||||
|
||||
### 14.1 Unit Tests
|
||||
- [ ] Reglas de calculo KPI M08.
|
||||
- [ ] Reglas de calculo KPI M09.
|
||||
- [ ] Reglas de calculo KPI M10.
|
||||
- [ ] Motor de scoring del simulador.
|
||||
- [ ] Clasificacion de severidad y rutas legales.
|
||||
- [ ] Normalizacion de extraccion IA de contrato.
|
||||
|
||||
### 14.2 Integration Tests
|
||||
- [ ] Flujo M5 -> M8 (creacion de contrato desde propuesta).
|
||||
- [ ] Flujo M08 completo (contrato -> entregable -> pago -> KPI).
|
||||
- [ ] Flujo M8 -> M9 (apertura de caso legal).
|
||||
- [ ] Flujo M9 -> M10 (caso/escrito visibles en expediente).
|
||||
- [ ] Flujo M7 con alertas de entregables vencidos y casos severos.
|
||||
|
||||
### 14.3 UI/E2E
|
||||
- [ ] Paridad visual de M08 (tabs, cards, modales, empty/data states).
|
||||
- [ ] Paridad visual de M09 (diagnostico, casos, escalada, escritos, directorio).
|
||||
- [ ] Paridad visual de M10 (simulador y expediente).
|
||||
- [ ] Responsive movil/escritorio en M08/M09/M10.
|
||||
- [ ] Accesibilidad basica de formularios y modales.
|
||||
|
||||
## 15. Release Gate (Definition of Done)
|
||||
|
||||
### 15.1 Calidad Tecnica
|
||||
- [ ] Migraciones aplicadas sin errores en entorno objetivo.
|
||||
- [ ] Seeds ejecutados y datos base disponibles.
|
||||
- [ ] Sin errores TypeScript en build.
|
||||
- [ ] Sin errores de lint bloqueantes.
|
||||
- [ ] Tests unitarios/integracion/UI en verde.
|
||||
|
||||
### 15.2 Verificaciones por Comando (orden recomendado)
|
||||
1. [ ] `npm run prisma:generate`
|
||||
2. [ ] `npm run prisma:migrate`
|
||||
3. [ ] `npm test`
|
||||
4. [ ] `npm run lint`
|
||||
5. [ ] `npm run build` (FINAL CHECK OBLIGATORIO)
|
||||
|
||||
### 15.3 Aprobacion Funcional Final
|
||||
- [ ] Demo E2E: M8 -> M9 -> M10 con datos reales de prueba.
|
||||
- [ ] Validacion de KPIs contra datos de ejemplo.
|
||||
- [ ] Validacion de guardado y recuperacion de evidencia/documentos.
|
||||
- [ ] Sign-off final de producto.
|
||||
@@ -0,0 +1,58 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProposalStatus" AS ENUM ('DRAFT', 'IN_PROGRESS', 'SUBMITTED', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Proposal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"sourceLicitationId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"issuingEntity" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL DEFAULT '',
|
||||
"status" "ProposalStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Proposal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProposalDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"proposalId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storedFileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"checksumSha256" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProposalDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Proposal_userId_status_idx" ON "Proposal"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Proposal_updatedAt_idx" ON "Proposal"("updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Proposal_sourceLicitationId_idx" ON "Proposal"("sourceLicitationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProposalDocument_proposalId_createdAt_idx" ON "ProposalDocument"("proposalId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProposalDocument_userId_createdAt_idx" ON "ProposalDocument"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProposalDocument" ADD CONSTRAINT "ProposalDocument_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProposalDocument" ADD CONSTRAINT "ProposalDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "LicitationSource" ADD VALUE IF NOT EXISTS 'LICITAYA';
|
||||
@@ -0,0 +1,81 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LicitationReviewStatus" AS ENUM ('NEW', 'REVIEWED', 'INTERESTED', 'DISCARDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NormativeDocumentType" AS ENUM ('BASES_LICITACION', 'CONVOCATORIA', 'REGLAMENTO', 'LEY', 'OTRO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NormativeAnalysisMethod" AS ENUM ('DIRECT', 'OCR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NormativeConfidence" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NormativeRiskLevel" AS ENUM ('ALTO', 'MEDIO', 'BAJO');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LicitationUserPreference" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"licitationId" TEXT NOT NULL,
|
||||
"status" "LicitationReviewStatus" NOT NULL DEFAULT 'NEW',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LicitationUserPreference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NormativeAnalysisHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"sourceLicitationId" TEXT,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"documentType" "NormativeDocumentType" NOT NULL,
|
||||
"issuingEntity" TEXT,
|
||||
"methodUsed" "NormativeAnalysisMethod" NOT NULL,
|
||||
"numPages" INTEGER NOT NULL,
|
||||
"warnings" JSONB,
|
||||
"extractedChars" INTEGER NOT NULL,
|
||||
"confidence" "NormativeConfidence" NOT NULL,
|
||||
"viabilityScore" INTEGER NOT NULL,
|
||||
"riskLevel" "NormativeRiskLevel" NOT NULL,
|
||||
"executiveSummary" TEXT NOT NULL,
|
||||
"result" JSONB NOT NULL,
|
||||
"analyzedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NormativeAnalysisHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LicitationUserPreference_userId_licitationId_key" ON "LicitationUserPreference"("userId", "licitationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LicitationUserPreference_userId_status_idx" ON "LicitationUserPreference"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LicitationUserPreference_licitationId_status_idx" ON "LicitationUserPreference"("licitationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NormativeAnalysisHistory_userId_analyzedAt_idx" ON "NormativeAnalysisHistory"("userId", "analyzedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NormativeAnalysisHistory_userId_deletedAt_idx" ON "NormativeAnalysisHistory"("userId", "deletedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NormativeAnalysisHistory_sourceLicitationId_idx" ON "NormativeAnalysisHistory"("sourceLicitationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LicitationUserPreference" ADD CONSTRAINT "LicitationUserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LicitationUserPreference" ADD CONSTRAINT "LicitationUserPreference_licitationId_fkey" FOREIGN KEY ("licitationId") REFERENCES "Licitation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NormativeAnalysisHistory" ADD CONSTRAINT "NormativeAnalysisHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NormativeAnalysisHistory" ADD CONSTRAINT "NormativeAnalysisHistory_sourceLicitationId_fkey" FOREIGN KEY ("sourceLicitationId") REFERENCES "Licitation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Proposal"
|
||||
ADD COLUMN "workflowDraft" JSONB,
|
||||
ADD COLUMN "currentStep" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "completionPercent" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "readyForSubmissionAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- RenameIndex (safe guard for environments where the old truncated index never existed)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"') IS NOT NULL THEN
|
||||
ALTER INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"
|
||||
RENAME TO "OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx";
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,134 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OfficialNormativeSourceType" AS ENUM ('LEY', 'REGLAMENTO', 'LINEAMIENTO', 'PORTAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NormativeVerificationStatus" AS ENUM ('SUCCESS', 'WARNING', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OfficialNormativeSource" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stateCode" TEXT NOT NULL,
|
||||
"stateName" TEXT NOT NULL,
|
||||
"municipalityCode" TEXT,
|
||||
"municipalityName" TEXT,
|
||||
"authorityName" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"officialUrl" TEXT NOT NULL,
|
||||
"sourceType" "OfficialNormativeSourceType" NOT NULL,
|
||||
"versionLabel" TEXT,
|
||||
"isPilot" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lastKnownHash" TEXT,
|
||||
"lastVerifiedAt" TIMESTAMP(3),
|
||||
"nextCheckAt" TIMESTAMP(3),
|
||||
"lastStatus" "NormativeVerificationStatus",
|
||||
"lastMessage" TEXT,
|
||||
"lastChangedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OfficialNormativeSource_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OfficialNormativeSuggestion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stateCode" TEXT NOT NULL,
|
||||
"municipalityCode" TEXT,
|
||||
"authorityName" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"officialUrl" TEXT NOT NULL,
|
||||
"sourceType" "OfficialNormativeSourceType" NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OfficialNormativeSuggestion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfficialNormativeSource_stateCode_municipalityCode_isPilot_idx" ON "OfficialNormativeSource"("stateCode", "municipalityCode", "isPilot");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfficialNormativeSource_nextCheckAt_idx" ON "OfficialNormativeSource"("nextCheckAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfficialNormativeSource_lastStatus_lastVerifiedAt_idx" ON "OfficialNormativeSource"("lastStatus", "lastVerifiedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdAt_idx" ON "OfficialNormativeSuggestion"("stateCode", "municipalityCode", "createdAt");
|
||||
|
||||
-- Seed pilot sources (Nuevo Leon)
|
||||
INSERT INTO "OfficialNormativeSource" (
|
||||
"id",
|
||||
"stateCode",
|
||||
"stateName",
|
||||
"municipalityCode",
|
||||
"municipalityName",
|
||||
"authorityName",
|
||||
"title",
|
||||
"officialUrl",
|
||||
"sourceType",
|
||||
"versionLabel",
|
||||
"isPilot",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'nl-ley-adquisiciones-estatal',
|
||||
'NL',
|
||||
'Nuevo Leon',
|
||||
NULL,
|
||||
NULL,
|
||||
'Gobierno del Estado de Nuevo Leon',
|
||||
'Ley de Adquisiciones, Arrendamientos y Contratacion de Servicios del Estado de Nuevo Leon',
|
||||
'https://www.hcnl.gob.mx/trabajo_legislativo/leyes/',
|
||||
'LEY',
|
||||
NULL,
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'nl-reglamento-adquisiciones-estatal',
|
||||
'NL',
|
||||
'Nuevo Leon',
|
||||
NULL,
|
||||
NULL,
|
||||
'Gobierno del Estado de Nuevo Leon',
|
||||
'Reglamento de la Ley de Adquisiciones del Estado de Nuevo Leon',
|
||||
'https://www.nl.gob.mx/',
|
||||
'REGLAMENTO',
|
||||
NULL,
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'spgg-reglamento-adquisiciones',
|
||||
'NL',
|
||||
'Nuevo Leon',
|
||||
'SPGG',
|
||||
'San Pedro Garza Garcia',
|
||||
'Municipio de San Pedro Garza Garcia',
|
||||
'Reglamento de Adquisiciones y Contratacion de Servicios de San Pedro Garza Garcia',
|
||||
'https://www.sanpedro.gob.mx/',
|
||||
'REGLAMENTO',
|
||||
NULL,
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT ("id") DO UPDATE
|
||||
SET
|
||||
"stateCode" = EXCLUDED."stateCode",
|
||||
"stateName" = EXCLUDED."stateName",
|
||||
"municipalityCode" = EXCLUDED."municipalityCode",
|
||||
"municipalityName" = EXCLUDED."municipalityName",
|
||||
"authorityName" = EXCLUDED."authorityName",
|
||||
"title" = EXCLUDED."title",
|
||||
"officialUrl" = EXCLUDED."officialUrl",
|
||||
"sourceType" = EXCLUDED."sourceType",
|
||||
"versionLabel" = EXCLUDED."versionLabel",
|
||||
"isPilot" = EXCLUDED."isPilot",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
376
prisma/migrations/20260406200000_modules8_9_10/migration.sql
Normal file
376
prisma/migrations/20260406200000_modules8_9_10/migration.sql
Normal file
@@ -0,0 +1,376 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContractStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContractDeliverableStatus" AS ENUM ('PENDING', 'DELIVERED', 'APPROVED', 'REJECTED', 'OVERDUE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContractPaymentStatus" AS ENUM ('REGISTERED', 'CONFIRMED', 'DISPUTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ContractDocumentKind" AS ENUM ('SIGNED_CONTRACT', 'ADDENDUM', 'DELIVERABLE_EVIDENCE', 'PAYMENT_EVIDENCE', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LegalCaseType" AS ENUM ('CONTRACT_BREACH', 'PAYMENT_RETENTION', 'UNJUST_SANCTION', 'CONTRACT_DISPUTE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LegalCaseSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LegalCaseStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ESCALATED', 'RESOLVED', 'CLOSED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LegalJurisdictionLevel" AS ENUM ('FEDERAL', 'STATE', 'MUNICIPAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditSimulationStatus" AS ENUM ('DRAFT', 'COMPLETED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditSimulationSectionStatus" AS ENUM ('READY', 'WARNING', 'CRITICAL');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"sourceProposalId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"counterpartyEntity" TEXT NOT NULL,
|
||||
"contractNumber" TEXT,
|
||||
"contractType" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"endDate" TIMESTAMP(3),
|
||||
"totalAmount" DECIMAL(14,2),
|
||||
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||
"status" "ContractStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContractRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractDeliverable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contractId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"amountLinked" DECIMAL(14,2),
|
||||
"status" "ContractDeliverableStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"notes" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContractDeliverable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractPayment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contractId" TEXT NOT NULL,
|
||||
"amount" DECIMAL(14,2) NOT NULL,
|
||||
"paymentDate" TIMESTAMP(3) NOT NULL,
|
||||
"invoiceNumber" TEXT,
|
||||
"concept" TEXT NOT NULL DEFAULT '',
|
||||
"status" "ContractPaymentStatus" NOT NULL DEFAULT 'REGISTERED',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContractPayment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contractId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"checksumSha256" TEXT,
|
||||
"kind" "ContractDocumentKind" NOT NULL DEFAULT 'OTHER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContractDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractExtractionHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contractId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"engine" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"resultJson" JSONB NOT NULL,
|
||||
"warningsJson" JSONB,
|
||||
"analyzedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ContractExtractionHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LegalCase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"contractId" TEXT,
|
||||
"caseType" "LegalCaseType" NOT NULL,
|
||||
"severity" "LegalCaseSeverity" NOT NULL,
|
||||
"counterparty" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"amountAtRisk" DECIMAL(14,2),
|
||||
"status" "LegalCaseStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LegalCase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LegalDiagnosis" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"legalCaseId" TEXT,
|
||||
"stepIndex" INTEGER NOT NULL DEFAULT 1,
|
||||
"totalSteps" INTEGER NOT NULL DEFAULT 4,
|
||||
"answersJson" JSONB NOT NULL,
|
||||
"recommendedRouteJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LegalDiagnosis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LegalEscalationStepLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"legalCaseId" TEXT NOT NULL,
|
||||
"routeStepKey" TEXT NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"notes" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LegalEscalationStepLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LegalDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"legalCaseId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"templateKey" TEXT,
|
||||
"aiGenerated" BOOLEAN NOT NULL DEFAULT false,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LegalDirectoryEntity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"jurisdictionLevel" "LegalJurisdictionLevel" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"scopeTagsJson" JSONB NOT NULL,
|
||||
"websiteUrl" TEXT,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"stateCode" TEXT,
|
||||
"municipalityCode" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LegalDirectoryEntity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditSimulation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"auditType" TEXT NOT NULL,
|
||||
"status" "AuditSimulationStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"overallScore" INTEGER,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AuditSimulation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditSimulationSection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"simulationId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"score" INTEGER,
|
||||
"status" "AuditSimulationSectionStatus" NOT NULL DEFAULT 'READY',
|
||||
"findingsJson" JSONB,
|
||||
"recommendationsJson" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AuditSimulationSection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditChecklistResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"simulationId" TEXT NOT NULL,
|
||||
"questionKey" TEXT NOT NULL,
|
||||
"answer" TEXT NOT NULL,
|
||||
"evidenceRefsJson" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AuditChecklistResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InstitutionalDossierSnapshot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"payloadJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InstitutionalDossierSnapshot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractRecord_userId_status_endDate_idx" ON "ContractRecord"("userId", "status", "endDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractRecord_sourceProposalId_idx" ON "ContractRecord"("sourceProposalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractRecord_updatedAt_idx" ON "ContractRecord"("updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractDeliverable_contractId_status_dueDate_idx" ON "ContractDeliverable"("contractId", "status", "dueDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractDeliverable_dueDate_idx" ON "ContractDeliverable"("dueDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractPayment_contractId_paymentDate_idx" ON "ContractPayment"("contractId", "paymentDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractPayment_status_paymentDate_idx" ON "ContractPayment"("status", "paymentDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractDocument_contractId_kind_createdAt_idx" ON "ContractDocument"("contractId", "kind", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractExtractionHistory_userId_analyzedAt_idx" ON "ContractExtractionHistory"("userId", "analyzedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContractExtractionHistory_contractId_analyzedAt_idx" ON "ContractExtractionHistory"("contractId", "analyzedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalCase_userId_status_severity_idx" ON "LegalCase"("userId", "status", "severity");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalCase_contractId_idx" ON "LegalCase"("contractId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDiagnosis_userId_updatedAt_idx" ON "LegalDiagnosis"("userId", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDiagnosis_legalCaseId_idx" ON "LegalDiagnosis"("legalCaseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LegalEscalationStepLog_legalCaseId_routeStepKey_key" ON "LegalEscalationStepLog"("legalCaseId", "routeStepKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalEscalationStepLog_legalCaseId_completedAt_idx" ON "LegalEscalationStepLog"("legalCaseId", "completedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDocument_userId_createdAt_idx" ON "LegalDocument"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDocument_legalCaseId_createdAt_idx" ON "LegalDocument"("legalCaseId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDirectoryEntity_jurisdictionLevel_isActive_idx" ON "LegalDirectoryEntity"("jurisdictionLevel", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LegalDirectoryEntity_stateCode_municipalityCode_isActive_idx" ON "LegalDirectoryEntity"("stateCode", "municipalityCode", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditSimulation_userId_status_updatedAt_idx" ON "AuditSimulation"("userId", "status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuditSimulationSection_simulationId_key_key" ON "AuditSimulationSection"("simulationId", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditSimulationSection_status_idx" ON "AuditSimulationSection"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditChecklistResponse_simulationId_questionKey_idx" ON "AuditChecklistResponse"("simulationId", "questionKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InstitutionalDossierSnapshot_userId_generatedAt_idx" ON "InstitutionalDossierSnapshot"("userId", "generatedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractRecord" ADD CONSTRAINT "ContractRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractRecord" ADD CONSTRAINT "ContractRecord_sourceProposalId_fkey" FOREIGN KEY ("sourceProposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractDeliverable" ADD CONSTRAINT "ContractDeliverable_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractPayment" ADD CONSTRAINT "ContractPayment_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractDocument" ADD CONSTRAINT "ContractDocument_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractExtractionHistory" ADD CONSTRAINT "ContractExtractionHistory_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractExtractionHistory" ADD CONSTRAINT "ContractExtractionHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalCase" ADD CONSTRAINT "LegalCase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalCase" ADD CONSTRAINT "LegalCase_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalDiagnosis" ADD CONSTRAINT "LegalDiagnosis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalDiagnosis" ADD CONSTRAINT "LegalDiagnosis_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalEscalationStepLog" ADD CONSTRAINT "LegalEscalationStepLog_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalDocument" ADD CONSTRAINT "LegalDocument_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LegalDocument" ADD CONSTRAINT "LegalDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditSimulation" ADD CONSTRAINT "AuditSimulation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditSimulationSection" ADD CONSTRAINT "AuditSimulationSection_simulationId_fkey" FOREIGN KEY ("simulationId") REFERENCES "AuditSimulation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditChecklistResponse" ADD CONSTRAINT "AuditChecklistResponse_simulationId_fkey" FOREIGN KEY ("simulationId") REFERENCES "AuditSimulation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InstitutionalDossierSnapshot" ADD CONSTRAINT "InstitutionalDossierSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Normalize index name across environments where Postgres truncated long identifiers.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"') IS NOT NULL
|
||||
AND to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx"') IS NULL THEN
|
||||
ALTER INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"
|
||||
RENAME TO "OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx";
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AiSuggestionStatus" AS ENUM ('GENERATED', 'ACCEPTED', 'DISMISSED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiSuggestion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"moduleKey" TEXT NOT NULL,
|
||||
"featureKey" TEXT NOT NULL,
|
||||
"subjectType" TEXT NOT NULL,
|
||||
"subjectId" TEXT NOT NULL,
|
||||
"inputHash" TEXT NOT NULL,
|
||||
"requestJson" JSONB NOT NULL,
|
||||
"responseJson" JSONB NOT NULL,
|
||||
"confidence" DOUBLE PRECISION,
|
||||
"engine" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"usageJson" JSONB,
|
||||
"warningsJson" JSONB,
|
||||
"promptVersion" TEXT NOT NULL,
|
||||
"status" "AiSuggestionStatus" NOT NULL DEFAULT 'GENERATED',
|
||||
"actedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AiSuggestion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ai_suggestion_dedupe" ON "AiSuggestion"("userId", "moduleKey", "featureKey", "subjectType", "subjectId", "inputHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiSuggestion_userId_moduleKey_featureKey_createdAt_idx" ON "AiSuggestion"("userId", "moduleKey", "featureKey", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiSuggestion_status_updatedAt_idx" ON "AiSuggestion"("status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiSuggestion_inputHash_idx" ON "AiSuggestion"("inputHash");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AiSuggestion" ADD CONSTRAINT "AiSuggestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ModulePlanKey" AS ENUM ('PLAN_2_4', 'PLAN_5_7', 'PLAN_8_10');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ModulePlanPurchaseStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ModulePlanPurchase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planKey" "ModulePlanKey" NOT NULL,
|
||||
"status" "ModulePlanPurchaseStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"externalReference" TEXT NOT NULL,
|
||||
"mercadoPreferenceId" TEXT,
|
||||
"mercadoPaymentId" TEXT,
|
||||
"mercadoOrderId" TEXT,
|
||||
"checkoutUrl" TEXT,
|
||||
"amount" DECIMAL(14,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||
"requestJson" JSONB,
|
||||
"responseJson" JSONB,
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ModulePlanPurchase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ModulePlanSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planKey" "ModulePlanKey" NOT NULL,
|
||||
"sourcePurchaseId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ModulePlanSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ModulePlanPurchase_externalReference_key" ON "ModulePlanPurchase"("externalReference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModulePlanPurchase_userId_planKey_createdAt_idx" ON "ModulePlanPurchase"("userId", "planKey", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModulePlanPurchase_status_updatedAt_idx" ON "ModulePlanPurchase"("status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModulePlanPurchase_mercadoPaymentId_idx" ON "ModulePlanPurchase"("mercadoPaymentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ModulePlanSubscription_userId_planKey_key" ON "ModulePlanSubscription"("userId", "planKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModulePlanSubscription_userId_isActive_expiresAt_idx" ON "ModulePlanSubscription"("userId", "isActive", "expiresAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ModulePlanPurchase" ADD CONSTRAINT "ModulePlanPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ModulePlanSubscription" ADD CONSTRAINT "ModulePlanSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ModulePlanSubscription" ADD CONSTRAINT "ModulePlanSubscription_sourcePurchaseId_fkey" FOREIGN KEY ("sourcePurchaseId") REFERENCES "ModulePlanPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -60,6 +60,7 @@ enum LicitationSource {
|
||||
MUNICIPAL_OPEN_PORTAL
|
||||
PNT
|
||||
MUNICIPAL_BACKUP
|
||||
LICITAYA
|
||||
}
|
||||
|
||||
enum LicitationProcedureType {
|
||||
@@ -83,11 +84,151 @@ enum SyncRunStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ProposalStatus {
|
||||
DRAFT
|
||||
IN_PROGRESS
|
||||
SUBMITTED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum LicitationReviewStatus {
|
||||
NEW
|
||||
REVIEWED
|
||||
INTERESTED
|
||||
DISCARDED
|
||||
}
|
||||
|
||||
enum NormativeDocumentType {
|
||||
BASES_LICITACION
|
||||
CONVOCATORIA
|
||||
REGLAMENTO
|
||||
LEY
|
||||
OTRO
|
||||
}
|
||||
|
||||
enum NormativeAnalysisMethod {
|
||||
DIRECT
|
||||
OCR
|
||||
}
|
||||
|
||||
enum NormativeConfidence {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
enum NormativeRiskLevel {
|
||||
ALTO
|
||||
MEDIO
|
||||
BAJO
|
||||
}
|
||||
|
||||
enum OfficialNormativeSourceType {
|
||||
LEY
|
||||
REGLAMENTO
|
||||
LINEAMIENTO
|
||||
PORTAL
|
||||
}
|
||||
|
||||
enum NormativeVerificationStatus {
|
||||
SUCCESS
|
||||
WARNING
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum MunicipalOpenPortalType {
|
||||
GENERIC
|
||||
SAN_PEDRO_ASPX
|
||||
}
|
||||
|
||||
enum ContractStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
PAUSED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ContractDeliverableStatus {
|
||||
PENDING
|
||||
DELIVERED
|
||||
APPROVED
|
||||
REJECTED
|
||||
OVERDUE
|
||||
}
|
||||
|
||||
enum ContractPaymentStatus {
|
||||
REGISTERED
|
||||
CONFIRMED
|
||||
DISPUTED
|
||||
}
|
||||
|
||||
enum ContractDocumentKind {
|
||||
SIGNED_CONTRACT
|
||||
ADDENDUM
|
||||
DELIVERABLE_EVIDENCE
|
||||
PAYMENT_EVIDENCE
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum LegalCaseType {
|
||||
CONTRACT_BREACH
|
||||
PAYMENT_RETENTION
|
||||
UNJUST_SANCTION
|
||||
CONTRACT_DISPUTE
|
||||
}
|
||||
|
||||
enum LegalCaseSeverity {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
enum LegalCaseStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
ESCALATED
|
||||
RESOLVED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum LegalJurisdictionLevel {
|
||||
FEDERAL
|
||||
STATE
|
||||
MUNICIPAL
|
||||
}
|
||||
|
||||
enum AuditSimulationStatus {
|
||||
DRAFT
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum AuditSimulationSectionStatus {
|
||||
READY
|
||||
WARNING
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum AiSuggestionStatus {
|
||||
GENERATED
|
||||
ACCEPTED
|
||||
DISMISSED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum ModulePlanKey {
|
||||
PLAN_2_4
|
||||
PLAN_5_7
|
||||
PLAN_8_10
|
||||
}
|
||||
|
||||
enum ModulePlanPurchaseStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
@@ -103,9 +244,23 @@ model User {
|
||||
strategicDiagnosticEvidenceDocs StrategicDiagnosticEvidenceDocument[]
|
||||
workshopProgresses DevelopmentWorkshopProgress[]
|
||||
workshopEvidenceDocs DevelopmentWorkshopEvidence[]
|
||||
proposals Proposal[]
|
||||
proposalDocuments ProposalDocument[]
|
||||
licitationPreferences LicitationUserPreference[]
|
||||
normativeAnalyses NormativeAnalysisHistory[]
|
||||
contracts ContractRecord[]
|
||||
contractExtractionHistories ContractExtractionHistory[]
|
||||
legalCases LegalCase[]
|
||||
legalDiagnoses LegalDiagnosis[]
|
||||
legalDocuments LegalDocument[]
|
||||
auditSimulations AuditSimulation[]
|
||||
institutionalDossierSnapshots InstitutionalDossierSnapshot[]
|
||||
verificationTokens EmailVerificationToken[]
|
||||
responses Response[]
|
||||
results AssessmentResult[]
|
||||
aiSuggestions AiSuggestion[]
|
||||
modulePlanPurchases ModulePlanPurchase[]
|
||||
modulePlanSubscriptions ModulePlanSubscription[]
|
||||
}
|
||||
|
||||
model Organization {
|
||||
@@ -384,6 +539,48 @@ model EmailVerificationToken {
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model ModulePlanPurchase {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
planKey ModulePlanKey
|
||||
status ModulePlanPurchaseStatus @default(PENDING)
|
||||
externalReference String @unique
|
||||
mercadoPreferenceId String?
|
||||
mercadoPaymentId String?
|
||||
mercadoOrderId String?
|
||||
checkoutUrl String?
|
||||
amount Decimal @db.Decimal(14, 2)
|
||||
currency String @default("MXN")
|
||||
requestJson Json?
|
||||
responseJson Json?
|
||||
approvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subscriptions ModulePlanSubscription[]
|
||||
|
||||
@@index([userId, planKey, createdAt])
|
||||
@@index([status, updatedAt])
|
||||
@@index([mercadoPaymentId])
|
||||
}
|
||||
|
||||
model ModulePlanSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
planKey ModulePlanKey
|
||||
sourcePurchaseId String?
|
||||
isActive Boolean @default(true)
|
||||
startsAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sourcePurchase ModulePlanPurchase? @relation(fields: [sourcePurchaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, planKey])
|
||||
@@index([userId, isActive, expiresAt])
|
||||
}
|
||||
|
||||
model Municipality {
|
||||
id String @id @default(cuid())
|
||||
stateCode String
|
||||
@@ -437,6 +634,8 @@ model Licitation {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
municipality Municipality @relation(fields: [municipalityId], references: [id], onDelete: Cascade)
|
||||
userPreferences LicitationUserPreference[]
|
||||
normativeAnalyses NormativeAnalysisHistory[]
|
||||
|
||||
@@unique([municipalityId, source, sourceRecordId])
|
||||
@@index([municipalityId, isOpen, closingDate])
|
||||
@@ -475,3 +674,400 @@ model CompanyProfile {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model LicitationUserPreference {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
licitationId String
|
||||
status LicitationReviewStatus @default(NEW)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
licitation Licitation @relation(fields: [licitationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, licitationId])
|
||||
@@index([userId, status])
|
||||
@@index([licitationId, status])
|
||||
}
|
||||
|
||||
model NormativeAnalysisHistory {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
sourceLicitationId String?
|
||||
fileName String
|
||||
documentType NormativeDocumentType
|
||||
issuingEntity String?
|
||||
methodUsed NormativeAnalysisMethod
|
||||
numPages Int
|
||||
warnings Json?
|
||||
extractedChars Int
|
||||
confidence NormativeConfidence
|
||||
viabilityScore Int
|
||||
riskLevel NormativeRiskLevel
|
||||
executiveSummary String
|
||||
result Json
|
||||
analyzedAt DateTime @default(now())
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sourceLicitation Licitation? @relation(fields: [sourceLicitationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, analyzedAt])
|
||||
@@index([userId, deletedAt])
|
||||
@@index([sourceLicitationId])
|
||||
}
|
||||
|
||||
model Proposal {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
sourceLicitationId String?
|
||||
title String
|
||||
issuingEntity String
|
||||
summary String @default("")
|
||||
workflowDraft Json?
|
||||
currentStep Int @default(1)
|
||||
completionPercent Int @default(0)
|
||||
readyForSubmissionAt DateTime?
|
||||
status ProposalStatus @default(DRAFT)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
documents ProposalDocument[]
|
||||
contracts ContractRecord[]
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([updatedAt])
|
||||
@@index([sourceLicitationId])
|
||||
}
|
||||
|
||||
model ContractRecord {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
sourceProposalId String?
|
||||
title String
|
||||
counterpartyEntity String
|
||||
contractNumber String?
|
||||
contractType String
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
totalAmount Decimal? @db.Decimal(14, 2)
|
||||
currency String @default("MXN")
|
||||
status ContractStatus @default(ACTIVE)
|
||||
description String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sourceProposal Proposal? @relation(fields: [sourceProposalId], references: [id], onDelete: SetNull)
|
||||
deliverables ContractDeliverable[]
|
||||
payments ContractPayment[]
|
||||
documents ContractDocument[]
|
||||
extractions ContractExtractionHistory[]
|
||||
legalCases LegalCase[]
|
||||
|
||||
@@index([userId, status, endDate])
|
||||
@@index([sourceProposalId])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
model ContractDeliverable {
|
||||
id String @id @default(cuid())
|
||||
contractId String
|
||||
title String
|
||||
dueDate DateTime?
|
||||
amountLinked Decimal? @db.Decimal(14, 2)
|
||||
status ContractDeliverableStatus @default(PENDING)
|
||||
deliveredAt DateTime?
|
||||
approvedAt DateTime?
|
||||
notes String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contractId, status, dueDate])
|
||||
@@index([dueDate])
|
||||
}
|
||||
|
||||
model ContractPayment {
|
||||
id String @id @default(cuid())
|
||||
contractId String
|
||||
amount Decimal @db.Decimal(14, 2)
|
||||
paymentDate DateTime
|
||||
invoiceNumber String?
|
||||
concept String @default("")
|
||||
status ContractPaymentStatus @default(REGISTERED)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contractId, paymentDate])
|
||||
@@index([status, paymentDate])
|
||||
}
|
||||
|
||||
model ContractDocument {
|
||||
id String @id @default(cuid())
|
||||
contractId String
|
||||
fileName String
|
||||
filePath String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
kind ContractDocumentKind @default(OTHER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contractId, kind, createdAt])
|
||||
}
|
||||
|
||||
model ContractExtractionHistory {
|
||||
id String @id @default(cuid())
|
||||
contractId String?
|
||||
userId String
|
||||
engine String
|
||||
model String?
|
||||
resultJson Json
|
||||
warningsJson Json?
|
||||
analyzedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
contract ContractRecord? @relation(fields: [contractId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, analyzedAt])
|
||||
@@index([contractId, analyzedAt])
|
||||
}
|
||||
|
||||
model LegalCase {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
contractId String?
|
||||
caseType LegalCaseType
|
||||
severity LegalCaseSeverity
|
||||
counterparty String
|
||||
description String
|
||||
amountAtRisk Decimal? @db.Decimal(14, 2)
|
||||
status LegalCaseStatus @default(OPEN)
|
||||
openedAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
contract ContractRecord? @relation(fields: [contractId], references: [id], onDelete: SetNull)
|
||||
diagnoses LegalDiagnosis[]
|
||||
escalationLogs LegalEscalationStepLog[]
|
||||
documents LegalDocument[]
|
||||
|
||||
@@index([userId, status, severity])
|
||||
@@index([contractId])
|
||||
}
|
||||
|
||||
model LegalDiagnosis {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
legalCaseId String?
|
||||
stepIndex Int @default(1)
|
||||
totalSteps Int @default(4)
|
||||
answersJson Json
|
||||
recommendedRouteJson Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
legalCase LegalCase? @relation(fields: [legalCaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@index([legalCaseId])
|
||||
}
|
||||
|
||||
model LegalEscalationStepLog {
|
||||
id String @id @default(cuid())
|
||||
legalCaseId String
|
||||
routeStepKey String
|
||||
completedAt DateTime?
|
||||
notes String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
legalCase LegalCase @relation(fields: [legalCaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([legalCaseId, routeStepKey])
|
||||
@@index([legalCaseId, completedAt])
|
||||
}
|
||||
|
||||
model LegalDocument {
|
||||
id String @id @default(cuid())
|
||||
legalCaseId String?
|
||||
userId String
|
||||
templateKey String?
|
||||
aiGenerated Boolean @default(false)
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
legalCase LegalCase? @relation(fields: [legalCaseId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([legalCaseId, createdAt])
|
||||
}
|
||||
|
||||
model LegalDirectoryEntity {
|
||||
id String @id @default(cuid())
|
||||
jurisdictionLevel LegalJurisdictionLevel
|
||||
name String
|
||||
scopeTagsJson Json
|
||||
websiteUrl String?
|
||||
phone String?
|
||||
email String?
|
||||
stateCode String?
|
||||
municipalityCode String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([jurisdictionLevel, isActive])
|
||||
@@index([stateCode, municipalityCode, isActive])
|
||||
}
|
||||
|
||||
model AuditSimulation {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
name String
|
||||
auditType String
|
||||
status AuditSimulationStatus @default(DRAFT)
|
||||
overallScore Int?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sections AuditSimulationSection[]
|
||||
responses AuditChecklistResponse[]
|
||||
|
||||
@@index([userId, status, updatedAt])
|
||||
}
|
||||
|
||||
model AuditSimulationSection {
|
||||
id String @id @default(cuid())
|
||||
simulationId String
|
||||
key String
|
||||
score Int?
|
||||
status AuditSimulationSectionStatus @default(READY)
|
||||
findingsJson Json?
|
||||
recommendationsJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
simulation AuditSimulation @relation(fields: [simulationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([simulationId, key])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model AuditChecklistResponse {
|
||||
id String @id @default(cuid())
|
||||
simulationId String
|
||||
questionKey String
|
||||
answer String
|
||||
evidenceRefsJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
simulation AuditSimulation @relation(fields: [simulationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([simulationId, questionKey])
|
||||
}
|
||||
|
||||
model InstitutionalDossierSnapshot {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
generatedAt DateTime @default(now())
|
||||
payloadJson Json
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, generatedAt])
|
||||
}
|
||||
|
||||
model ProposalDocument {
|
||||
id String @id @default(cuid())
|
||||
proposalId String
|
||||
userId String
|
||||
fileName String
|
||||
storedFileName String
|
||||
filePath String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksumSha256 String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([proposalId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model OfficialNormativeSource {
|
||||
id String @id
|
||||
stateCode String
|
||||
stateName String
|
||||
municipalityCode String?
|
||||
municipalityName String?
|
||||
authorityName String
|
||||
title String
|
||||
officialUrl String
|
||||
sourceType OfficialNormativeSourceType
|
||||
versionLabel String?
|
||||
isPilot Boolean @default(false)
|
||||
lastKnownHash String?
|
||||
lastVerifiedAt DateTime?
|
||||
nextCheckAt DateTime?
|
||||
lastStatus NormativeVerificationStatus?
|
||||
lastMessage String?
|
||||
lastChangedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stateCode, municipalityCode, isPilot])
|
||||
@@index([nextCheckAt])
|
||||
@@index([lastStatus, lastVerifiedAt])
|
||||
}
|
||||
|
||||
model OfficialNormativeSuggestion {
|
||||
id String @id @default(cuid())
|
||||
stateCode String
|
||||
municipalityCode String?
|
||||
authorityName String
|
||||
title String
|
||||
officialUrl String
|
||||
sourceType OfficialNormativeSourceType
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stateCode, municipalityCode, createdAt])
|
||||
}
|
||||
|
||||
model AiSuggestion {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
moduleKey String
|
||||
featureKey String
|
||||
subjectType String
|
||||
subjectId String
|
||||
inputHash String
|
||||
requestJson Json
|
||||
responseJson Json
|
||||
confidence Float?
|
||||
engine String
|
||||
model String?
|
||||
usageJson Json?
|
||||
warningsJson Json?
|
||||
promptVersion String
|
||||
status AiSuggestionStatus @default(GENERATED)
|
||||
actedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, moduleKey, featureKey, subjectType, subjectId, inputHash], map: "ai_suggestion_dedupe")
|
||||
@@index([userId, moduleKey, featureKey, createdAt])
|
||||
@@index([status, updatedAt])
|
||||
@@index([inputHash])
|
||||
}
|
||||
|
||||
140
prisma/seed.mjs
140
prisma/seed.mjs
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient, ContentPageType, OverallScoreMethod, PriorityLevel } from "@prisma/client";
|
||||
import { PrismaClient, ContentPageType, LegalJurisdictionLevel, OverallScoreMethod, PriorityLevel } from "@prisma/client";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -389,6 +389,99 @@ const contentPageSeeds = [
|
||||
},
|
||||
];
|
||||
|
||||
const legalDirectorySeeds = [
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.FEDERAL,
|
||||
name: "Secretaria de la Funcion Publica (SFP)",
|
||||
scopeTagsJson: ["inconformidades", "sanciones", "organo-interno-control"],
|
||||
websiteUrl: "https://www.gob.mx/sfp",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: null,
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.FEDERAL,
|
||||
name: "Tribunal Federal de Justicia Administrativa (TFJA)",
|
||||
scopeTagsJson: ["juicio-contencioso", "nulidad", "sanciones-administrativas"],
|
||||
websiteUrl: "https://www.tfja.gob.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: null,
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.FEDERAL,
|
||||
name: "Auditoria Superior de la Federacion (ASF)",
|
||||
scopeTagsJson: ["auditoria", "fiscalizacion", "cuenta-publica"],
|
||||
websiteUrl: "https://www.asf.gob.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: null,
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.FEDERAL,
|
||||
name: "CompraNet",
|
||||
scopeTagsJson: ["contrataciones", "licitaciones", "expedientes"],
|
||||
websiteUrl: "https://compranet.hacienda.gob.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: null,
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.FEDERAL,
|
||||
name: "Sistema Nacional Anticorrupcion",
|
||||
scopeTagsJson: ["anticorrupcion", "denuncias", "integridad"],
|
||||
websiteUrl: "https://www.sna.org.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: null,
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.STATE,
|
||||
name: "Secretaria de la Contraloria y Transparencia Gubernamental de Nuevo Leon",
|
||||
scopeTagsJson: ["inconformidades", "contraloria", "responsabilidades"],
|
||||
websiteUrl: "https://www.nl.gob.mx/dependencias/contraloria",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: "NL",
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.STATE,
|
||||
name: "Tribunal de Justicia Administrativa de Nuevo Leon",
|
||||
scopeTagsJson: ["juicio-administrativo", "sanciones", "nulidad"],
|
||||
websiteUrl: "https://tjanl.gob.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: "NL",
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.STATE,
|
||||
name: "Auditoria Superior del Estado de Nuevo Leon",
|
||||
scopeTagsJson: ["auditoria", "fiscalizacion", "cuenta-publica-estatal"],
|
||||
websiteUrl: "https://www.asenl.gob.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: "NL",
|
||||
municipalityCode: null,
|
||||
},
|
||||
{
|
||||
jurisdictionLevel: LegalJurisdictionLevel.STATE,
|
||||
name: "Sistema Estatal Anticorrupcion de Nuevo Leon",
|
||||
scopeTagsJson: ["anticorrupcion", "politica-estatal", "coordinacion"],
|
||||
websiteUrl: "https://www.seanl.mx",
|
||||
phone: null,
|
||||
email: null,
|
||||
stateCode: "NL",
|
||||
municipalityCode: null,
|
||||
},
|
||||
];
|
||||
|
||||
async function upsertDiagnosticStructure() {
|
||||
const moduleKeys = moduleSeeds.map((moduleSeed) => moduleSeed.key);
|
||||
|
||||
@@ -605,6 +698,48 @@ async function upsertDefaultScoringConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertLegalDirectory() {
|
||||
for (const item of legalDirectorySeeds) {
|
||||
const existing = await prisma.legalDirectoryEntity.findFirst({
|
||||
where: {
|
||||
jurisdictionLevel: item.jurisdictionLevel,
|
||||
name: item.name,
|
||||
stateCode: item.stateCode,
|
||||
municipalityCode: item.municipalityCode,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.legalDirectoryEntity.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
scopeTagsJson: item.scopeTagsJson,
|
||||
websiteUrl: item.websiteUrl,
|
||||
phone: item.phone,
|
||||
email: item.email,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.legalDirectoryEntity.create({
|
||||
data: {
|
||||
jurisdictionLevel: item.jurisdictionLevel,
|
||||
name: item.name,
|
||||
scopeTagsJson: item.scopeTagsJson,
|
||||
websiteUrl: item.websiteUrl,
|
||||
phone: item.phone,
|
||||
email: item.email,
|
||||
stateCode: item.stateCode,
|
||||
municipalityCode: item.municipalityCode,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMunicipalitySeeds() {
|
||||
const filePath = path.join(__dirname, "data", "municipalities.json");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
@@ -697,6 +832,7 @@ async function main() {
|
||||
await upsertRecommendations();
|
||||
await upsertContentPages();
|
||||
await upsertDefaultScoringConfig();
|
||||
await upsertLegalDirectory();
|
||||
const municipalitySeedCount = await upsertMunicipalities();
|
||||
|
||||
const moduleCount = await prisma.diagnosticModule.count();
|
||||
@@ -705,6 +841,7 @@ async function main() {
|
||||
const workshopCount = await prisma.developmentWorkshop.count();
|
||||
const recommendationCount = await prisma.recommendation.count();
|
||||
const contentPageCount = await prisma.contentPage.count();
|
||||
const legalDirectoryCount = await prisma.legalDirectoryEntity.count({ where: { isActive: true } });
|
||||
const municipalityCount = await prisma.municipality.count({ where: { isActive: true } });
|
||||
|
||||
console.log("Seed completed", {
|
||||
@@ -714,6 +851,7 @@ async function main() {
|
||||
workshops: workshopCount,
|
||||
recommendations: recommendationCount,
|
||||
contentPages: contentPageCount,
|
||||
legalDirectory: legalDirectoryCount,
|
||||
municipalities: municipalityCount,
|
||||
municipalitySeedsProcessed: municipalitySeedCount,
|
||||
});
|
||||
|
||||
578
scripts/backfill-licitaya-history.mjs
Normal file
578
scripts/backfill-licitaya-history.mjs
Normal file
@@ -0,0 +1,578 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { PrismaClient, LicitationCategory, LicitationProcedureType, LicitationSource, MunicipalOpenPortalType } from "@prisma/client";
|
||||
|
||||
const DEFAULT_ITEMS = 50;
|
||||
const DEFAULT_MAX_PAGES = 5;
|
||||
const DEFAULT_DAYS = 30;
|
||||
const DEFAULT_DELAY_MS = 400;
|
||||
const LICITAYA_MUNICIPALITY_CODE = "LICITAYA";
|
||||
|
||||
const LICITAYA_STATE_MAP = {
|
||||
AGS: { stateCode: "1", stateName: "Aguascalientes" },
|
||||
BCN: { stateCode: "2", stateName: "Baja California" },
|
||||
BCS: { stateCode: "3", stateName: "Baja California Sur" },
|
||||
CAM: { stateCode: "4", stateName: "Campeche" },
|
||||
CHP: { stateCode: "5", stateName: "Chiapas" },
|
||||
CHH: { stateCode: "6", stateName: "Chihuahua" },
|
||||
COA: { stateCode: "7", stateName: "Coahuila" },
|
||||
COL: { stateCode: "8", stateName: "Colima" },
|
||||
CMX: { stateCode: "9", stateName: "Ciudad de Mexico" },
|
||||
CDMX: { stateCode: "9", stateName: "Ciudad de Mexico" },
|
||||
DUR: { stateCode: "10", stateName: "Durango" },
|
||||
GUA: { stateCode: "11", stateName: "Guanajuato" },
|
||||
GRO: { stateCode: "12", stateName: "Guerrero" },
|
||||
HID: { stateCode: "13", stateName: "Hidalgo" },
|
||||
JAL: { stateCode: "14", stateName: "Jalisco" },
|
||||
MEX: { stateCode: "15", stateName: "Estado de Mexico" },
|
||||
MIC: { stateCode: "16", stateName: "Michoacan" },
|
||||
MOR: { stateCode: "17", stateName: "Morelos" },
|
||||
NAY: { stateCode: "18", stateName: "Nayarit" },
|
||||
NLE: { stateCode: "19", stateName: "Nuevo Leon" },
|
||||
OAX: { stateCode: "20", stateName: "Oaxaca" },
|
||||
PUE: { stateCode: "21", stateName: "Puebla" },
|
||||
QUE: { stateCode: "22", stateName: "Queretaro" },
|
||||
ROO: { stateCode: "23", stateName: "Quintana Roo" },
|
||||
SLP: { stateCode: "24", stateName: "San Luis Potosi" },
|
||||
SIN: { stateCode: "25", stateName: "Sinaloa" },
|
||||
SON: { stateCode: "26", stateName: "Sonora" },
|
||||
TAB: { stateCode: "27", stateName: "Tabasco" },
|
||||
TAM: { stateCode: "28", stateName: "Tamaulipas" },
|
||||
TLA: { stateCode: "29", stateName: "Tlaxcala" },
|
||||
VER: { stateCode: "30", stateName: "Veracruz" },
|
||||
YUC: { stateCode: "31", stateName: "Yucatan" },
|
||||
ZAC: { stateCode: "32", stateName: "Zacatecas" },
|
||||
};
|
||||
|
||||
function loadDotenv(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
|
||||
if (!key || process.env[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
days: parseInteger(process.env.LICITAYA_BACKFILL_DAYS, DEFAULT_DAYS),
|
||||
items: parseInteger(process.env.LICITAYA_BACKFILL_ITEMS, DEFAULT_ITEMS),
|
||||
maxPages: parseInteger(process.env.LICITAYA_BACKFILL_MAX_PAGES, DEFAULT_MAX_PAGES),
|
||||
delayMs: parseInteger(process.env.LICITAYA_BACKFILL_DELAY_MS, DEFAULT_DELAY_MS),
|
||||
from: process.env.LICITAYA_BACKFILL_FROM || null,
|
||||
to: process.env.LICITAYA_BACKFILL_TO || null,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (current === "--days" && next) {
|
||||
args.days = parseInteger(next, args.days);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--items" && next) {
|
||||
args.items = parseInteger(next, args.items);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--max-pages" && next) {
|
||||
args.maxPages = parseInteger(next, args.maxPages);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--delay-ms" && next) {
|
||||
args.delayMs = parseInteger(next, args.delayMs);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--from" && next) {
|
||||
args.from = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--to" && next) {
|
||||
args.to = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
args.days = Math.max(1, args.days);
|
||||
args.items = Math.min(100, Math.max(1, args.items));
|
||||
args.maxPages = Math.max(1, args.maxPages);
|
||||
args.delayMs = Math.max(0, args.delayMs);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseDateYmd(value) {
|
||||
if (!value || !/^\d{8}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number.parseInt(value.slice(0, 4), 10);
|
||||
const month = Number.parseInt(value.slice(4, 6), 10);
|
||||
const day = Number.parseInt(value.slice(6, 8), 10);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDateYmd(date) {
|
||||
const year = String(date.getUTCFullYear());
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function buildDateRange(args) {
|
||||
const toDate = parseDateYmd(args.to) ?? new Date();
|
||||
|
||||
if (args.from) {
|
||||
const fromDate = parseDateYmd(args.from);
|
||||
|
||||
if (!fromDate) {
|
||||
throw new Error("Invalid --from date. Use YYYYMMDD.");
|
||||
}
|
||||
|
||||
const dates = [];
|
||||
const cursor = new Date(Date.UTC(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate()));
|
||||
const floor = new Date(Date.UTC(fromDate.getUTCFullYear(), fromDate.getUTCMonth(), fromDate.getUTCDate()));
|
||||
|
||||
while (cursor >= floor) {
|
||||
dates.push(formatDateYmd(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
const dates = [];
|
||||
const cursor = new Date(Date.UTC(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate()));
|
||||
|
||||
for (let i = 0; i < args.days; i += 1) {
|
||||
dates.push(formatDateYmd(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateMaybe(value) {
|
||||
const raw = toText(value);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(raw);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function mapProcedureType(value) {
|
||||
const text = (toText(value) || "").toLowerCase();
|
||||
|
||||
if (text.includes("adjudicacion directa")) {
|
||||
return LicitationProcedureType.ADJUDICACION_DIRECTA;
|
||||
}
|
||||
|
||||
if (text.includes("invitacion restringida") || text.includes("invitacion a cuando menos")) {
|
||||
return LicitationProcedureType.INVITACION_RESTRINGIDA;
|
||||
}
|
||||
|
||||
if (text.includes("licitacion publica") || text.includes("licitacion")) {
|
||||
return LicitationProcedureType.LICITACION_PUBLICA;
|
||||
}
|
||||
|
||||
return LicitationProcedureType.UNKNOWN;
|
||||
}
|
||||
|
||||
function mapCategory(title, description) {
|
||||
const text = `${title || ""} ${description || ""}`.toLowerCase();
|
||||
const goods = text.includes("bien") || text.includes("insumo") || text.includes("material");
|
||||
const services = text.includes("servicio") || text.includes("consultoria");
|
||||
const works = text.includes("obra") || text.includes("construccion") || text.includes("infraestructura");
|
||||
const matched = [goods, services, works].filter(Boolean).length;
|
||||
|
||||
if (matched > 1) {
|
||||
return LicitationCategory.MIXED;
|
||||
}
|
||||
|
||||
if (goods) {
|
||||
return LicitationCategory.GOODS;
|
||||
}
|
||||
|
||||
if (services) {
|
||||
return LicitationCategory.SERVICES;
|
||||
}
|
||||
|
||||
if (works) {
|
||||
return LicitationCategory.WORKS;
|
||||
}
|
||||
|
||||
return LicitationCategory.UNKNOWN;
|
||||
}
|
||||
|
||||
function sanitizeDocuments(record) {
|
||||
const docs = [];
|
||||
|
||||
if (toText(record.url)) {
|
||||
docs.push({ name: "Ficha LicitaYa", url: String(record.url) });
|
||||
}
|
||||
|
||||
if (toText(record.url2)) {
|
||||
docs.push({ name: "Fuente alterna", url: String(record.url2) });
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
|
||||
return docs.filter((doc) => {
|
||||
if (seen.has(doc.url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(doc.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLicitayaState(rawState, stateNamesByCode) {
|
||||
const token = (toText(rawState) || "").toUpperCase().replace(/[^A-Z]/g, "");
|
||||
const mapped = LICITAYA_STATE_MAP[token] ?? null;
|
||||
|
||||
if (!mapped) {
|
||||
return {
|
||||
stateCode: "00",
|
||||
stateName: "Cobertura nacional",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stateCode: mapped.stateCode,
|
||||
stateName: stateNamesByCode.get(mapped.stateCode) || mapped.stateName,
|
||||
};
|
||||
}
|
||||
|
||||
async function delay(ms) {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadDotenv(resolve(process.cwd(), ".env"));
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const dates = buildDateRange(args);
|
||||
const apiKey = process.env.LICITAYA_API_KEY?.trim();
|
||||
const baseUrl = (process.env.LICITAYA_BASE_URL?.trim() || "https://www.licitaya.com.mx/api/v1").replace(/\/+$/, "");
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("LICITAYA_API_KEY is required.");
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
const stateRows = await prisma.municipality.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
municipalityCode: {
|
||||
not: LICITAYA_MUNICIPALITY_CODE,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
stateCode: true,
|
||||
stateName: true,
|
||||
},
|
||||
distinct: ["stateCode"],
|
||||
});
|
||||
|
||||
const stateNamesByCode = new Map(stateRows.map((row) => [row.stateCode, row.stateName]));
|
||||
const municipalityCache = new Map();
|
||||
|
||||
async function ensureLicitayaMunicipality(stateCode, stateName) {
|
||||
const key = `${stateCode}|${stateName}`;
|
||||
|
||||
if (municipalityCache.has(key)) {
|
||||
return municipalityCache.get(key);
|
||||
}
|
||||
|
||||
const municipality = await prisma.municipality.upsert({
|
||||
where: {
|
||||
stateCode_municipalityCode: {
|
||||
stateCode,
|
||||
municipalityCode: LICITAYA_MUNICIPALITY_CODE,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
stateName,
|
||||
municipalityName: `Cobertura LicitaYa (${stateName})`,
|
||||
isActive: true,
|
||||
scrapingEnabled: false,
|
||||
openPortalType: MunicipalOpenPortalType.GENERIC,
|
||||
openPortalUrl: null,
|
||||
backupUrl: null,
|
||||
pntEntryUrl: null,
|
||||
pntEntityId: null,
|
||||
pntSectorId: null,
|
||||
pntSubjectId: null,
|
||||
},
|
||||
create: {
|
||||
stateCode,
|
||||
stateName,
|
||||
municipalityCode: LICITAYA_MUNICIPALITY_CODE,
|
||||
municipalityName: `Cobertura LicitaYa (${stateName})`,
|
||||
openPortalType: MunicipalOpenPortalType.GENERIC,
|
||||
isActive: true,
|
||||
scrapingEnabled: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
municipalityCache.set(key, municipality);
|
||||
return municipality;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
datesAttempted: dates.length,
|
||||
pagesAttempted: 0,
|
||||
emptyDates: 0,
|
||||
fetched: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
warnings: [],
|
||||
dryRun: args.dryRun,
|
||||
};
|
||||
|
||||
for (const date of dates) {
|
||||
let dateHadResults = false;
|
||||
|
||||
for (let page = 1; page <= args.maxPages; page += 1) {
|
||||
const requestUrl = new URL("tender/search", `${baseUrl}/`);
|
||||
requestUrl.searchParams.set("date", date);
|
||||
requestUrl.searchParams.set("items", String(args.items));
|
||||
requestUrl.searchParams.set("page", String(page));
|
||||
requestUrl.searchParams.set("order", "1");
|
||||
|
||||
summary.pagesAttempted += 1;
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
"X-API-KEY": apiKey,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
summary.errors += 1;
|
||||
summary.warnings.push(`HTTP ${response.status} for ${requestUrl.toString()}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null));
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
summary.errors += 1;
|
||||
summary.warnings.push(`Invalid JSON for ${requestUrl.toString()}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(payload.results) ? payload.results : [];
|
||||
|
||||
if (!rows.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
dateHadResults = true;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = row;
|
||||
const tenderId = toText(record.tenderId);
|
||||
const title = toText(record.tender_object) || toText(record.expanded_search) || (tenderId ? `Licitacion ${tenderId}` : null);
|
||||
|
||||
if (!tenderId || !title) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.fetched += 1;
|
||||
|
||||
const description = toText(record.expanded_search) || toText(record.extra_info) || null;
|
||||
const publishDate = parseDateMaybe(record.catalog_date);
|
||||
const closingDate = parseDateMaybe(record.close_date);
|
||||
const state = resolveLicitayaState(record.state, stateNamesByCode);
|
||||
const municipality = await ensureLicitayaMunicipality(state.stateCode, state.stateName);
|
||||
|
||||
if (args.dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceRecordId = tenderId.slice(0, 255);
|
||||
const existing = await prisma.licitation.findUnique({
|
||||
where: {
|
||||
municipalityId_source_sourceRecordId: {
|
||||
municipalityId: municipality.id,
|
||||
source: LicitationSource.LICITAYA,
|
||||
sourceRecordId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.licitation.upsert({
|
||||
where: {
|
||||
municipalityId_source_sourceRecordId: {
|
||||
municipalityId: municipality.id,
|
||||
source: LicitationSource.LICITAYA,
|
||||
sourceRecordId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
tenderCode: toText(record.number) || toText(record.number2) || toText(record.reference) || toText(record.process) || null,
|
||||
procedureType: mapProcedureType(record.type),
|
||||
title,
|
||||
description,
|
||||
category: mapCategory(title, description),
|
||||
isOpen: closingDate ? closingDate >= new Date() : true,
|
||||
closingDate,
|
||||
publishDate,
|
||||
supplierAwarded: toText(record.agency),
|
||||
documents: sanitizeDocuments(record),
|
||||
rawSourceUrl: requestUrl.toString(),
|
||||
rawPayload: {
|
||||
source: "licitaya-backfill",
|
||||
licitayaState: toText(record.state),
|
||||
licitayaCity: toText(record.city),
|
||||
record,
|
||||
},
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
municipalityId: municipality.id,
|
||||
source: LicitationSource.LICITAYA,
|
||||
sourceRecordId,
|
||||
tenderCode: toText(record.number) || toText(record.number2) || toText(record.reference) || toText(record.process) || null,
|
||||
procedureType: mapProcedureType(record.type),
|
||||
title,
|
||||
description,
|
||||
category: mapCategory(title, description),
|
||||
isOpen: closingDate ? closingDate >= new Date() : true,
|
||||
closingDate,
|
||||
publishDate,
|
||||
supplierAwarded: toText(record.agency),
|
||||
documents: sanitizeDocuments(record),
|
||||
rawSourceUrl: requestUrl.toString(),
|
||||
rawPayload: {
|
||||
source: "licitaya-backfill",
|
||||
licitayaState: toText(record.state),
|
||||
licitayaCity: toText(record.city),
|
||||
record,
|
||||
},
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
summary.updated += 1;
|
||||
} else {
|
||||
summary.inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Number.parseInt(toText(payload.total_pages) || "1", 10);
|
||||
|
||||
if (!Number.isFinite(totalPages) || page >= totalPages || page >= args.maxPages) {
|
||||
break;
|
||||
}
|
||||
|
||||
await delay(args.delayMs);
|
||||
}
|
||||
|
||||
if (!dateHadResults) {
|
||||
summary.emptyDates += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,6 +2,25 @@ import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const DEFAULT_ENDPOINT = "/tender/SCRZJ";
|
||||
|
||||
function parseBoolean(value, fallback = false) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (["1", "true", "yes", "si"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (["0", "false", "no"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function loadDotenv(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
@@ -40,10 +59,11 @@ function loadDotenv(filePath) {
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
baseUrl: process.env.LICITAYA_BASE_URL,
|
||||
endpoint: process.env.LICITAYA_TEST_ENDPOINT,
|
||||
endpoint: process.env.LICITAYA_TEST_ENDPOINT || DEFAULT_ENDPOINT,
|
||||
accept: process.env.LICITAYA_ACCEPT || "application/json",
|
||||
method: "GET",
|
||||
timeoutMs: Number.parseInt(process.env.LICITAYA_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS,
|
||||
allowEmptySearch: parseBoolean(process.env.LICITAYA_ALLOW_EMPTY_SEARCH, true),
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
@@ -82,6 +102,11 @@ function parseArgs(argv) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--allow-empty-search") {
|
||||
args.allowEmptySearch = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
@@ -176,10 +201,17 @@ try {
|
||||
console.log("--- Response Preview ---");
|
||||
console.log(bodyPreview);
|
||||
|
||||
if (response.status === 404 && url.pathname.endsWith("/tender/search")) {
|
||||
const isEmptySearch404 = response.status === 404 && url.pathname.endsWith("/tender/search");
|
||||
|
||||
if (isEmptySearch404) {
|
||||
console.error("No tenders matched the current filters. Try a broader keyword or fewer filters.");
|
||||
}
|
||||
|
||||
if (isEmptySearch404 && args.allowEmptySearch) {
|
||||
console.log("Connectivity check passed (search endpoint returned 404 with no matches).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -18,6 +19,8 @@ export async function POST(request: Request) {
|
||||
skip?: number;
|
||||
targetYear?: number;
|
||||
includePnt?: boolean;
|
||||
includeLicitaya?: boolean;
|
||||
includeRegulations?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,11 +29,23 @@ export async function POST(request: Request) {
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
skip: typeof body.skip === "number" ? body.skip : undefined,
|
||||
targetYear: typeof body.targetYear === "number" ? body.targetYear : undefined,
|
||||
includePnt: body.includePnt === true,
|
||||
force: body.force === true,
|
||||
includePnt: typeof body.includePnt === "boolean" ? body.includePnt : undefined,
|
||||
includeLicitaya: typeof body.includeLicitaya === "boolean" ? body.includeLicitaya : undefined,
|
||||
force: typeof body.force === "boolean" ? body.force : undefined,
|
||||
});
|
||||
const includeRegulations = body.includeRegulations !== false;
|
||||
let regulations = null;
|
||||
let regulationsError: string | null = null;
|
||||
|
||||
return NextResponse.json({ ok: true, payload });
|
||||
if (includeRegulations) {
|
||||
try {
|
||||
regulations = await runPeriodicNormativeVerification(new Date());
|
||||
} catch (error) {
|
||||
regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales.";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, payload, regulations, regulationsError });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
35
src/app/api/ai/suggestions/[id]/decision/route.ts
Normal file
35
src/app/api/ai/suggestions/[id]/decision/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { applyAiSuggestionDecision } from "@/lib/ai/suggestions";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseDecision(value: unknown) {
|
||||
return value === "accept" || value === "dismiss" ? value : null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const decision = parseDecision(body.decision);
|
||||
|
||||
if (!id || !decision) {
|
||||
return NextResponse.json({ error: "Decision invalida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await applyAiSuggestionDecision(session.userId, id, decision);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Sugerencia no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, suggestion: updated });
|
||||
}
|
||||
188
src/app/api/audits/ai/findings/route.ts
Normal file
188
src/app/api/audits/ai/findings/route.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { buildInstitutionalDossier, getAuditSimulationDetail, getLatestInstitutionalDossierSnapshot, listAuditSimulationsForUser } from "@/lib/audits/server";
|
||||
|
||||
const M10_PROMPT_VERSION = "m10_findings_v1";
|
||||
|
||||
const M10FindingsSchema = z.object({
|
||||
auditorLikelyFindings: z
|
||||
.array(
|
||||
z.object({
|
||||
area: z.string().min(4).max(120),
|
||||
finding: z.string().min(8).max(500),
|
||||
severity: z.enum(["alto", "medio", "bajo"]),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
missingEvidence: z.array(z.string().min(6).max(280)).max(20),
|
||||
topRisks: z.array(z.string().min(6).max(280)).max(12),
|
||||
remediationPlan: z
|
||||
.array(
|
||||
z.object({
|
||||
step: z.string().min(8).max(420),
|
||||
priority: z.enum(["alta", "media", "baja"]),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDate: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M10FindingsJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["auditorLikelyFindings", "missingEvidence", "topRisks", "remediationPlan", "confidence"],
|
||||
properties: {
|
||||
auditorLikelyFindings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["area", "finding", "severity"],
|
||||
properties: {
|
||||
area: { type: "string" },
|
||||
finding: { type: "string" },
|
||||
severity: { type: "string", enum: ["alto", "medio", "bajo"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingEvidence: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
topRisks: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
remediationPlan: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["step", "priority", "ownerSuggestion", "targetDate"],
|
||||
properties: {
|
||||
step: { type: "string" },
|
||||
priority: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDate: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function resolveSimulation(userId: string, simulationId: string) {
|
||||
if (simulationId) {
|
||||
return getAuditSimulationDetail(userId, simulationId);
|
||||
}
|
||||
|
||||
const simulations = await listAuditSimulationsForUser(userId);
|
||||
return simulations.find((simulation) => simulation.status === "COMPLETED") ?? simulations[0] ?? null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const simulationId = parseString(body.simulationId);
|
||||
const simulation = await resolveSimulation(user.id, simulationId);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "No hay simulaciones disponibles para generar dictamen IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestDossier = await getLatestInstitutionalDossierSnapshot(user.id);
|
||||
const dossier = latestDossier?.payload ?? (await buildInstitutionalDossier(user.id));
|
||||
|
||||
const condensedContext = {
|
||||
simulation: {
|
||||
id: simulation.id,
|
||||
name: simulation.name,
|
||||
auditType: simulation.auditType,
|
||||
status: simulation.status,
|
||||
overallScore: simulation.overallScore,
|
||||
completedAt: simulation.completedAt,
|
||||
sections: simulation.sections,
|
||||
},
|
||||
dossier: {
|
||||
generatedAt: dossier.generatedAt,
|
||||
summary: dossier.summary,
|
||||
components: dossier.components,
|
||||
},
|
||||
};
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres auditor preventivo especializado en contratacion publica y cumplimiento documental en Mexico.",
|
||||
"Simula hallazgos probables de una revision formal sin modificar resultados deterministas existentes.",
|
||||
"Debes responder solo JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Contexto actual de simulacion y expediente institucional:",
|
||||
JSON.stringify(condensedContext),
|
||||
"",
|
||||
"Genera:",
|
||||
"- auditorLikelyFindings: hallazgos probables por area y severidad.",
|
||||
"- missingEvidence: evidencia concreta faltante.",
|
||||
"- topRisks: riesgos principales priorizados.",
|
||||
"- remediationPlan: plan de remediacion con prioridad, responsable sugerido y fecha objetivo.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M10_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M10FindingsSchema,
|
||||
schemaName: "m10_audit_findings",
|
||||
jsonSchema: M10FindingsJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M10_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const payload =
|
||||
envelope.data ??
|
||||
({
|
||||
auditorLikelyFindings: [],
|
||||
missingEvidence: [],
|
||||
topRisks: [],
|
||||
remediationPlan: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M10FindingsSchema>);
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M10",
|
||||
featureKey: "audit_findings",
|
||||
subjectType: "audit_simulation",
|
||||
subjectId: simulation.id,
|
||||
inputForHash: condensedContext,
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
simulationId: simulation.id,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
21
src/app/api/audits/expediente/refresh/route.ts
Normal file
21
src/app/api/audits/expediente/refresh/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { refreshInstitutionalDossierSnapshot } from "@/lib/audits/server";
|
||||
|
||||
export async function POST() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const snapshot = await refreshInstitutionalDossierSnapshot(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
snapshotId: snapshot.id,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
dossier: snapshot.payload,
|
||||
freshness: snapshot.freshness,
|
||||
});
|
||||
}
|
||||
25
src/app/api/audits/expediente/route.ts
Normal file
25
src/app/api/audits/expediente/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getInstitutionalDossierForUser } from "@/lib/audits/server";
|
||||
|
||||
function parseStrategy(value: string | null) {
|
||||
return value === "snapshot" ? "snapshot" : "live";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const strategy = parseStrategy(searchParams.get("strategy"));
|
||||
const resolved = await getInstitutionalDossierForUser(user.id, strategy);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
dossier: resolved.dossier,
|
||||
freshness: resolved.freshness,
|
||||
});
|
||||
}
|
||||
24
src/app/api/audits/simulations/[id]/route.ts
Normal file
24
src/app/api/audits/simulations/[id]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getAuditSimulationDetail } from "@/lib/audits/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const simulation = await getAuditSimulationDetail(user.id, id);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
36
src/app/api/audits/simulations/[id]/score/route.ts
Normal file
36
src/app/api/audits/simulations/[id]/score/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { AuditSimulationScoreInputSchema } from "@/lib/audits/types";
|
||||
import { scoreAuditSimulationForUser } from "@/lib/audits/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = AuditSimulationScoreInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Payload de scoring invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const simulation = await scoreAuditSimulationForUser(user.id, id, parsed.data);
|
||||
|
||||
if (!simulation) {
|
||||
return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
39
src/app/api/audits/simulations/route.ts
Normal file
39
src/app/api/audits/simulations/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import {
|
||||
AuditSimulationCreateInputSchema,
|
||||
} from "@/lib/audits/types";
|
||||
import { createAuditSimulationForUser, getAuditKpisForUser, listAuditSimulationsForUser } from "@/lib/audits/server";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [simulations, kpis] = await Promise.all([listAuditSimulationsForUser(user.id), getAuditKpisForUser(user.id)]);
|
||||
return NextResponse.json({ ok: true, simulations, kpis });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = AuditSimulationCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de simulacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const simulation = await createAuditSimulationForUser(user.id, parsed.data);
|
||||
return NextResponse.json({ ok: true, simulation });
|
||||
}
|
||||
169
src/app/api/compliance/m7/ai/playbook/route.ts
Normal file
169
src/app/api/compliance/m7/ai/playbook/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
|
||||
const M7_PROMPT_VERSION = "m7_playbook_v1";
|
||||
|
||||
const M7PlaybookSchema = z.object({
|
||||
predictedIncidents: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(8).max(220),
|
||||
likelihood: z.enum(["alta", "media", "baja"]),
|
||||
impact: z.enum(["alto", "medio", "bajo"]),
|
||||
timeHorizon: z.string().min(4).max(80),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
priorityOrder: z.array(z.string().min(8).max(220)).max(12),
|
||||
preventiveActions: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.string().min(8).max(400),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDate: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
escalationAdvice: z.array(z.string().min(8).max(420)).max(8),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M7PlaybookJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["predictedIncidents", "priorityOrder", "preventiveActions", "escalationAdvice", "confidence"],
|
||||
properties: {
|
||||
predictedIncidents: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "likelihood", "impact", "timeHorizon"],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
likelihood: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
impact: { type: "string", enum: ["alto", "medio", "bajo"] },
|
||||
timeHorizon: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
priorityOrder: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
preventiveActions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action", "ownerSuggestion", "targetDate"],
|
||||
properties: {
|
||||
action: { type: "string" },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDate: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
escalationAdvice: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseDataset(value: unknown): M7Dataset | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as M7Dataset;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const providedDataset = parseDataset(body.dataset);
|
||||
const dataset = providedDataset ?? (await getM7DatasetForUser(user.id));
|
||||
|
||||
const condensedDataset = {
|
||||
generatedAt: dataset.generatedAt,
|
||||
kpis: dataset.kpis,
|
||||
m3States: dataset.m3States,
|
||||
deadlines: dataset.tabs.plazos.slice(0, 25),
|
||||
alerts: dataset.tabs.alertas.slice(0, 40),
|
||||
checklist: dataset.tabs.checklist.slice(0, 25),
|
||||
};
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres un especialista en cumplimiento para contratacion publica en Mexico.",
|
||||
"Construye un playbook preventivo con enfoque operativo para los proximos 30 dias.",
|
||||
"No cambies severidades ni estados existentes: solo sugiere acciones.",
|
||||
"Responde solo JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Dataset actual de M7:",
|
||||
JSON.stringify(condensedDataset),
|
||||
"",
|
||||
"Genera:",
|
||||
"- predictedIncidents: incidentes probables (sin inventar datos externos).",
|
||||
"- priorityOrder: orden de atencion recomendado.",
|
||||
"- preventiveActions: acciones con ownerSuggestion y targetDate.",
|
||||
"- escalationAdvice: criterios breves para escalar a legal/direccion.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M7_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M7PlaybookSchema,
|
||||
schemaName: "m7_playbook",
|
||||
jsonSchema: M7PlaybookJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M7_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const payload =
|
||||
envelope.data ??
|
||||
({
|
||||
predictedIncidents: [],
|
||||
priorityOrder: [],
|
||||
preventiveActions: [],
|
||||
escalationAdvice: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M7PlaybookSchema>);
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M7",
|
||||
featureKey: "compliance_playbook",
|
||||
subjectType: "m7_dataset",
|
||||
subjectId: dataset.generatedAt,
|
||||
inputForHash: condensedDataset,
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/app/api/compliance/m7/route.ts
Normal file
14
src/app/api/compliance/m7/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const dataset = await getM7DatasetForUser(user.id);
|
||||
return NextResponse.json({ ok: true, dataset });
|
||||
}
|
||||
75
src/app/api/contracts/[id]/deliverables/route.ts
Normal file
75
src/app/api/contracts/[id]/deliverables/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DeliverableCreateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = DeliverableCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de entregable invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const deliverable = await prisma.contractDeliverable.create({
|
||||
data: {
|
||||
contractId,
|
||||
title: input.title,
|
||||
dueDate: input.dueDate ? new Date(input.dueDate) : null,
|
||||
amountLinked: input.amountLinked ?? null,
|
||||
notes: input.notes ?? "",
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deliverable: {
|
||||
id: deliverable.id,
|
||||
contractId: deliverable.contractId,
|
||||
title: deliverable.title,
|
||||
dueDate: deliverable.dueDate ? deliverable.dueDate.toISOString() : null,
|
||||
amountLinked: deliverable.amountLinked ? Number(deliverable.amountLinked) : null,
|
||||
status: deliverable.status,
|
||||
deliveredAt: deliverable.deliveredAt ? deliverable.deliveredAt.toISOString() : null,
|
||||
approvedAt: deliverable.approvedAt ? deliverable.approvedAt.toISOString() : null,
|
||||
notes: deliverable.notes,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible crear el entregable." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, readStoredContractDocumentFileMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
readStoredContractDocumentFileMock: vi.fn(),
|
||||
prismaMock: {
|
||||
contractDocument: {
|
||||
findFirst: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/contracts/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/contracts/storage")>("@/lib/contracts/storage");
|
||||
return {
|
||||
...actual,
|
||||
readStoredContractDocumentFile: readStoredContractDocumentFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/contracts/[id]/documents/[documentId]/route";
|
||||
|
||||
describe("contract document download API security", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 for non-owned or missing contract document", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.contractDocument.findFirst.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "c1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toContain("Documento no encontrado");
|
||||
expect(readStoredContractDocumentFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when contract storage path is invalid", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.contractDocument.findFirst.mockResolvedValue({
|
||||
fileName: "contrato.pdf",
|
||||
filePath: "../../escape.pdf",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
readStoredContractDocumentFileMock.mockRejectedValue(new Error("CONTRACT_DOCUMENT_INVALID_PATH"));
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "c1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("Ruta de almacenamiento invalida");
|
||||
});
|
||||
});
|
||||
150
src/app/api/contracts/[id]/documents/[documentId]/route.ts
Normal file
150
src/app/api/contracts/[id]/documents/[documentId]/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readStoredContractDocumentFile, removeStoredContractDocumentFile } from "@/lib/contracts/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function safeContentDispositionFilename(value: string) {
|
||||
const normalized = value.trim().replace(/[\r\n"]/g, "_");
|
||||
return normalized || "contrato-documento";
|
||||
}
|
||||
|
||||
function mapDocumentReadError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_NOT_FOUND") {
|
||||
return { status: 404, message: "Archivo no encontrado en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_INVALID_PATH") {
|
||||
return { status: 400, message: "Ruta de almacenamiento invalida." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_EMPTY") {
|
||||
return { status: 400, message: "Archivo vacio en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "CONTRACT_DOCUMENT_TOO_LARGE") {
|
||||
return { status: 400, message: "Archivo excede el limite permitido." };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await prisma.contractDocument.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
contractId: id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = await readStoredContractDocumentFile(document.filePath);
|
||||
} catch (error) {
|
||||
const mapped = mapDocumentReadError(error);
|
||||
if (mapped) {
|
||||
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encodedFilename = encodeURIComponent(document.fileName);
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await prisma.contractDocument.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
contractId: id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
filePath: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await removeStoredContractDocumentFile(document.filePath);
|
||||
await prisma.contractDocument.delete({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
116
src/app/api/contracts/[id]/payments/route.ts
Normal file
116
src/app/api/contracts/[id]/payments/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PaymentCreateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const payments = await prisma.contractPayment.findMany({
|
||||
where: {
|
||||
contractId,
|
||||
},
|
||||
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payments: payments.map((item) => ({
|
||||
id: item.id,
|
||||
contractId: item.contractId,
|
||||
amount: Number(item.amount),
|
||||
paymentDate: item.paymentDate.toISOString(),
|
||||
invoiceNumber: item.invoiceNumber,
|
||||
concept: item.concept,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: contractId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = PaymentCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de pago invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const payment = await prisma.contractPayment.create({
|
||||
data: {
|
||||
contractId,
|
||||
amount: input.amount,
|
||||
paymentDate: new Date(input.paymentDate),
|
||||
invoiceNumber: input.invoiceNumber ?? null,
|
||||
concept: input.concept,
|
||||
status: input.status ?? "REGISTERED",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payment: {
|
||||
id: payment.id,
|
||||
contractId: payment.contractId,
|
||||
amount: Number(payment.amount),
|
||||
paymentDate: payment.paymentDate.toISOString(),
|
||||
invoiceNumber: payment.invoiceNumber,
|
||||
concept: payment.concept,
|
||||
status: payment.status,
|
||||
createdAt: payment.createdAt.toISOString(),
|
||||
updatedAt: payment.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible registrar el pago." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/contracts/[id]/route.ts
Normal file
70
src/app/api/contracts/[id]/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ContractUpdateInputSchema } from "@/lib/contracts/types";
|
||||
import { listContractsForUser } from "@/lib/contracts/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = ContractUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const current = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
await prisma.contractRecord.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: input.title,
|
||||
counterpartyEntity: input.counterpartyEntity,
|
||||
contractNumber: input.contractNumber === undefined ? undefined : input.contractNumber ?? null,
|
||||
contractType: input.contractType,
|
||||
startDate: input.startDate === undefined ? undefined : input.startDate ? new Date(input.startDate) : null,
|
||||
endDate: input.endDate === undefined ? undefined : input.endDate ? new Date(input.endDate) : null,
|
||||
totalAmount: input.totalAmount === undefined ? undefined : input.totalAmount ?? null,
|
||||
currency: input.currency ? input.currency.toUpperCase() : undefined,
|
||||
status: input.status,
|
||||
description: input.description,
|
||||
sourceProposalId: input.sourceProposalId === undefined ? undefined : input.sourceProposalId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const contracts = await listContractsForUser(user.id, { q: id });
|
||||
const contract = contracts.find((item) => item.id === id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, contract });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el contrato." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
297
src/app/api/contracts/extract/route.ts
Normal file
297
src/app/api/contracts/extract/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { contractCreateInputFromExtraction } from "@/lib/contracts/server";
|
||||
import { extractContractWithAi } from "@/lib/contracts/extraction";
|
||||
import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage";
|
||||
import {
|
||||
ensureProposalPdfLinkedToContract,
|
||||
getLatestProposalPdfSourceForUser,
|
||||
} from "@/lib/contracts/proposal-continuity";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function removePdfExtension(fileName: string) {
|
||||
return fileName.replace(/\.pdf$/i, "").trim();
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const contractIdRaw = formData.get("contractId");
|
||||
const contractId = typeof contractIdRaw === "string" ? contractIdRaw.trim() : "";
|
||||
const sourceProposalIdRaw = formData.get("sourceProposalId");
|
||||
const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() : "";
|
||||
const fileValue = formData.get("file");
|
||||
const uploadedFile = fileValue instanceof File ? fileValue : null;
|
||||
|
||||
logContinuityHandoff("m5_to_m8_extract_requested", {
|
||||
userId: user.id,
|
||||
contractId: contractId || null,
|
||||
sourceProposalId: sourceProposalId || null,
|
||||
hasUploadedFile: Boolean(uploadedFile),
|
||||
});
|
||||
|
||||
let sourceProposalLookup: Awaited<ReturnType<typeof getLatestProposalPdfSourceForUser>> | null = null;
|
||||
let resolvedSourceProposalId: string | null = null;
|
||||
if (sourceProposalId) {
|
||||
try {
|
||||
sourceProposalLookup = await getLatestProposalPdfSourceForUser(user.id, sourceProposalId);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible leer el PDF de la propuesta fuente." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceProposalLookup.status === "proposal_not_found") {
|
||||
return NextResponse.json({ error: "Propuesta fuente no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
resolvedSourceProposalId = sourceProposalId;
|
||||
logContinuityHandoff("m5_to_m8_source_proposal_resolved", {
|
||||
userId: user.id,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
lookupStatus: sourceProposalLookup.status,
|
||||
});
|
||||
}
|
||||
|
||||
let fileName = "";
|
||||
let mimeType = "application/pdf";
|
||||
let fileBuffer: Buffer;
|
||||
|
||||
if (uploadedFile) {
|
||||
if (uploadedFile.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (uploadedFile.size > MAX_CONTRACT_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(uploadedFile)) {
|
||||
return NextResponse.json({ error: "Solo se permite PDF en extraccion de contrato." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileName = uploadedFile.name;
|
||||
mimeType = uploadedFile.type || "application/pdf";
|
||||
fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
} else {
|
||||
if (!sourceProposalLookup) {
|
||||
return NextResponse.json({ error: "Debes adjuntar un contrato PDF o seleccionar una propuesta de Modulo 5." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceProposalLookup.status !== "ok") {
|
||||
return NextResponse.json({ error: "La propuesta fuente no tiene un PDF disponible para reutilizar." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileName = sourceProposalLookup.source.document.fileName;
|
||||
mimeType = sourceProposalLookup.source.document.mimeType;
|
||||
fileBuffer = sourceProposalLookup.source.fileBuffer;
|
||||
}
|
||||
|
||||
const analyzed = await analyzePdf(fileBuffer);
|
||||
const extraction = await extractContractWithAi(analyzed.text);
|
||||
|
||||
let contract = null as { id: string } | null;
|
||||
|
||||
if (contractId) {
|
||||
const existing = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const base = contractCreateInputFromExtraction({
|
||||
extraction,
|
||||
fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo",
|
||||
});
|
||||
|
||||
await prisma.contractRecord.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
title: extraction.fields.title ? base.title : undefined,
|
||||
counterpartyEntity: extraction.fields.counterpartyEntity ? base.counterpartyEntity : undefined,
|
||||
contractNumber: extraction.fields.contractNumber === null ? undefined : base.contractNumber,
|
||||
contractType: extraction.fields.contractType ? base.contractType : undefined,
|
||||
startDate: extraction.fields.startDate ? new Date(extraction.fields.startDate) : undefined,
|
||||
endDate: extraction.fields.endDate ? new Date(extraction.fields.endDate) : undefined,
|
||||
totalAmount: extraction.fields.totalAmount === null ? undefined : base.totalAmount,
|
||||
currency: base.currency,
|
||||
description: extraction.fields.description ? base.description : undefined,
|
||||
sourceProposalId: resolvedSourceProposalId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
contract = existing;
|
||||
} else {
|
||||
const base = contractCreateInputFromExtraction({
|
||||
extraction,
|
||||
fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo",
|
||||
});
|
||||
|
||||
contract = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
title: base.title,
|
||||
counterpartyEntity: base.counterpartyEntity,
|
||||
contractNumber: base.contractNumber,
|
||||
contractType: base.contractType,
|
||||
startDate: base.startDate ? new Date(base.startDate) : null,
|
||||
endDate: base.endDate ? new Date(base.endDate) : null,
|
||||
totalAmount: base.totalAmount,
|
||||
currency: base.currency,
|
||||
status: base.status,
|
||||
description: base.description,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const targetContractId = contract.id;
|
||||
logContinuityHandoff("m5_to_m8_contract_target_resolved", {
|
||||
userId: user.id,
|
||||
targetContractId,
|
||||
sourceProposalId: resolvedSourceProposalId,
|
||||
mode: contractId ? "update" : "create",
|
||||
});
|
||||
|
||||
const [deliverableCount, paymentCount] = await Promise.all([
|
||||
prisma.contractDeliverable.count({
|
||||
where: {
|
||||
contractId: targetContractId,
|
||||
},
|
||||
}),
|
||||
prisma.contractPayment.count({
|
||||
where: {
|
||||
contractId: targetContractId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (deliverableCount === 0 && extraction.deliverables.length > 0) {
|
||||
await prisma.contractDeliverable.createMany({
|
||||
data: extraction.deliverables.map((item) => ({
|
||||
contractId: targetContractId,
|
||||
title: item.title,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
amountLinked: item.amountLinked,
|
||||
notes: item.notes ?? "Detectado automaticamente por IA.",
|
||||
status: "PENDING",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentCount === 0 && extraction.paymentMilestones.length > 0) {
|
||||
await prisma.contractPayment.createMany({
|
||||
data: extraction.paymentMilestones
|
||||
.filter((item) => item.paymentDate)
|
||||
.map((item) => ({
|
||||
contractId: targetContractId,
|
||||
amount: item.amount ?? 0,
|
||||
paymentDate: new Date(item.paymentDate as string),
|
||||
concept: item.concept,
|
||||
status: "REGISTERED",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let uploadedDocumentId: string | null = null;
|
||||
if (uploadedFile) {
|
||||
const stored = await storeContractDocumentFile(user.id, targetContractId, fileName, mimeType, fileBuffer);
|
||||
const createdDocument = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: targetContractId,
|
||||
fileName: stored.fileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
kind: "SIGNED_CONTRACT",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
uploadedDocumentId = createdDocument.id;
|
||||
} else if (sourceProposalLookup?.status === "ok") {
|
||||
const linked = await ensureProposalPdfLinkedToContract({
|
||||
userId: user.id,
|
||||
contractId: targetContractId,
|
||||
source: sourceProposalLookup.source,
|
||||
});
|
||||
uploadedDocumentId = linked.documentId;
|
||||
}
|
||||
|
||||
await prisma.contractExtractionHistory.create({
|
||||
data: {
|
||||
contractId: targetContractId,
|
||||
userId: user.id,
|
||||
engine: extraction.engine,
|
||||
model: extraction.model,
|
||||
resultJson: extraction as unknown as Prisma.InputJsonValue,
|
||||
warningsJson: extraction.warnings as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const fullContract = await prisma.contractRecord.findUnique({
|
||||
where: { id: targetContractId },
|
||||
include: {
|
||||
deliverables: {
|
||||
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
payments: {
|
||||
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
|
||||
},
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
contract: fullContract,
|
||||
extraction: {
|
||||
...extraction,
|
||||
analyzed: {
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
},
|
||||
},
|
||||
uploadedDocumentId,
|
||||
});
|
||||
}
|
||||
14
src/app/api/contracts/kpis/route.ts
Normal file
14
src/app/api/contracts/kpis/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getContractsKpisForUser } from "@/lib/contracts/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const kpis = await getContractsKpisForUser(user.id);
|
||||
return NextResponse.json({ ok: true, kpis });
|
||||
}
|
||||
100
src/app/api/contracts/route.ts
Normal file
100
src/app/api/contracts/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
ContractCreateInputSchema,
|
||||
ContractListFiltersSchema,
|
||||
} from "@/lib/contracts/types";
|
||||
import { listContractsForUser } from "@/lib/contracts/server";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = ContractListFiltersSchema.safeParse({
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
status: searchParams.get("status") ?? undefined,
|
||||
sort: searchParams.get("sort") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contracts = await listContractsForUser(user.id, parsed.data);
|
||||
return NextResponse.json({ ok: true, contracts });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener contratos." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = ContractCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de contrato invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const created = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: input.sourceProposalId ?? null,
|
||||
title: input.title,
|
||||
counterpartyEntity: input.counterpartyEntity,
|
||||
contractNumber: input.contractNumber ?? null,
|
||||
contractType: input.contractType,
|
||||
startDate: input.startDate ? new Date(input.startDate) : null,
|
||||
endDate: input.endDate ? new Date(input.endDate) : null,
|
||||
totalAmount: input.totalAmount ?? null,
|
||||
currency: (input.currency ?? "MXN").toUpperCase(),
|
||||
status: input.status ?? "ACTIVE",
|
||||
description: input.description ?? "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const contracts = await listContractsForUser(user.id, { q: created.id });
|
||||
const contract = contracts.find((item) => item.id === created.id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, contract });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible crear el contrato." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
103
src/app/api/contracts/upload/route.ts
Normal file
103
src/app/api/contracts/upload/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function parseKind(value: FormDataEntryValue | null) {
|
||||
const asString = typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
|
||||
if (
|
||||
asString === "SIGNED_CONTRACT" ||
|
||||
asString === "ADDENDUM" ||
|
||||
asString === "DELIVERABLE_EVIDENCE" ||
|
||||
asString === "PAYMENT_EVIDENCE" ||
|
||||
asString === "OTHER"
|
||||
) {
|
||||
return asString;
|
||||
}
|
||||
|
||||
return "SIGNED_CONTRACT";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const contractIdValue = formData.get("contractId");
|
||||
const fileValue = formData.get("file");
|
||||
|
||||
if (!(fileValue instanceof File)) {
|
||||
return NextResponse.json({ error: "Debes adjuntar un archivo." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (fileValue.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (fileValue.size > MAX_CONTRACT_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(fileValue)) {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF para este flujo." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contractId = typeof contractIdValue === "string" ? contractIdValue.trim() : "";
|
||||
|
||||
if (!contractId) {
|
||||
return NextResponse.json({ error: "contractId es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const contract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(await fileValue.arrayBuffer());
|
||||
const stored = await storeContractDocumentFile(user.id, contractId, fileValue.name, fileValue.type, fileBuffer);
|
||||
const kind = parseKind(formData.get("kind"));
|
||||
|
||||
const document = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId,
|
||||
fileName: stored.fileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
document: {
|
||||
id: document.id,
|
||||
contractId: document.contractId,
|
||||
fileName: document.fileName,
|
||||
filePath: document.filePath,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
checksumSha256: document.checksumSha256,
|
||||
kind: document.kind,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { licitationsConfig } from "@/lib/licitations/config";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { isAuthorizedCronRequest } from "@/lib/cron/auth";
|
||||
import { runDailyLicitationsSync } from "@/lib/licitations/sync";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -31,10 +32,8 @@ function parseBoolean(value: string | null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = request.headers.get("x-sync-token") ?? "";
|
||||
|
||||
if (!licitationsConfig.syncCronToken || token !== licitationsConfig.syncCronToken) {
|
||||
async function runSync(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -44,11 +43,33 @@ export async function POST(request: Request) {
|
||||
const skip = parsePositiveInt(url.searchParams.get("skip"));
|
||||
const targetYear = parsePositiveInt(url.searchParams.get("target_year"));
|
||||
const includePnt = parseBoolean(url.searchParams.get("include_pnt"));
|
||||
const includeLicitaya = parseBoolean(url.searchParams.get("include_licitaya"));
|
||||
const includeRegulations = parseBoolean(url.searchParams.get("include_regulations"));
|
||||
const force = parseBoolean(url.searchParams.get("force"));
|
||||
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, force });
|
||||
const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, includeLicitaya, force });
|
||||
let regulations = null;
|
||||
let regulationsError: string | null = null;
|
||||
|
||||
if (includeRegulations !== false) {
|
||||
try {
|
||||
regulations = await runPeriodicNormativeVerification(new Date());
|
||||
} catch (error) {
|
||||
regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales.";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payload,
|
||||
regulations,
|
||||
regulationsError,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return runSync(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return runSync(request);
|
||||
}
|
||||
|
||||
25
src/app/api/cron/regulations-verify/route.ts
Normal file
25
src/app/api/cron/regulations-verify/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations";
|
||||
import { isAuthorizedCronRequest } from "@/lib/cron/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function runVerification(request: Request) {
|
||||
if (!isAuthorizedCronRequest(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await runPeriodicNormativeVerification(new Date());
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return runVerification(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return runVerification(request);
|
||||
}
|
||||
77
src/app/api/deliverables/[id]/route.ts
Normal file
77
src/app/api/deliverables/[id]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DeliverableUpdateInputSchema } from "@/lib/contracts/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = DeliverableUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const deliverable = await prisma.contractDeliverable.findFirst({
|
||||
where: {
|
||||
id,
|
||||
contract: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!deliverable) {
|
||||
return NextResponse.json({ error: "Entregable no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const updated = await prisma.contractDeliverable.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
dueDate: input.dueDate === undefined ? undefined : input.dueDate ? new Date(input.dueDate) : null,
|
||||
deliveredAt: input.deliveredAt === undefined ? undefined : input.deliveredAt ? new Date(input.deliveredAt) : null,
|
||||
approvedAt: input.approvedAt === undefined ? undefined : input.approvedAt ? new Date(input.approvedAt) : null,
|
||||
notes: input.notes,
|
||||
amountLinked: input.amountLinked === undefined ? undefined : input.amountLinked ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deliverable: {
|
||||
id: updated.id,
|
||||
contractId: updated.contractId,
|
||||
title: updated.title,
|
||||
dueDate: updated.dueDate ? updated.dueDate.toISOString() : null,
|
||||
amountLinked: updated.amountLinked ? Number(updated.amountLinked) : null,
|
||||
status: updated.status,
|
||||
deliveredAt: updated.deliveredAt ? updated.deliveredAt.toISOString() : null,
|
||||
approvedAt: updated.approvedAt ? updated.approvedAt.toISOString() : null,
|
||||
notes: updated.notes,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el entregable." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
309
src/app/api/diagnostic/ai/suggestions/route.ts
Normal file
309
src/app/api/diagnostic/ai/suggestions/route.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
|
||||
const M1_PROMPT_VERSION = "m1_assist_v1";
|
||||
|
||||
const M1AiSuggestionItemSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
suggestedAnswerOptionId: z.string().min(1),
|
||||
rationale: z.string().min(10).max(1_500),
|
||||
missingEvidence: z.array(z.string().min(2).max(240)).max(8).default([]),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
const M1AiSuggestionResponseSchema = z.object({
|
||||
suggestions: z.array(M1AiSuggestionItemSchema).max(50),
|
||||
confidence: z.enum(["low", "medium", "high"]).optional(),
|
||||
});
|
||||
|
||||
const M1AiSuggestionJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["suggestions"],
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["questionId", "suggestedAnswerOptionId", "rationale", "missingEvidence"],
|
||||
properties: {
|
||||
questionId: { type: "string" },
|
||||
suggestedAnswerOptionId: { type: "string" },
|
||||
rationale: { type: "string" },
|
||||
missingEvidence: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseResponseEvidence(rawValue: unknown): { notes: string; links: string[] } | null {
|
||||
if (!rawValue || typeof rawValue !== "object" || Array.isArray(rawValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = rawValue as Record<string, unknown>;
|
||||
const notes = typeof value.notes === "string" ? value.notes.trim() : "";
|
||||
const links = Array.isArray(value.links)
|
||||
? value.links
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
|
||||
if (!notes && links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
function toConfidenceNumber(confidence: number | undefined, fallback: "low" | "medium" | "high" | null) {
|
||||
if (typeof confidence === "number" && Number.isFinite(confidence)) {
|
||||
return Math.max(0, Math.min(1, confidence));
|
||||
}
|
||||
|
||||
if (fallback === "high") {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
if (fallback === "medium") {
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
if (fallback === "low") {
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const moduleKey = parseString(body.moduleKey);
|
||||
const requestedQuestionId = parseString(body.questionId);
|
||||
|
||||
if (!moduleKey) {
|
||||
return NextResponse.json({ error: "moduleKey es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const moduleRecord = await prisma.diagnosticModule.findUnique({
|
||||
where: { key: moduleKey },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
questions: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
prompt: true,
|
||||
helpText: true,
|
||||
answerOptions: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!moduleRecord) {
|
||||
return NextResponse.json({ error: "Modulo no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const questionIds = moduleRecord.questions.map((question) => question.id);
|
||||
const responses = questionIds.length
|
||||
? await prisma.response.findMany({
|
||||
where: {
|
||||
userId: session.userId,
|
||||
questionId: {
|
||||
in: questionIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
questionId: true,
|
||||
answerOptionId: true,
|
||||
evidence: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const responseByQuestionId = new Map(responses.map((item) => [item.questionId, item]));
|
||||
const targetQuestions = moduleRecord.questions.filter((question) => {
|
||||
if (requestedQuestionId) {
|
||||
return question.id === requestedQuestionId;
|
||||
}
|
||||
|
||||
return !responseByQuestionId.has(question.id);
|
||||
});
|
||||
|
||||
if (requestedQuestionId && !targetQuestions.length) {
|
||||
return NextResponse.json({ error: "questionId no pertenece al modulo solicitado." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!targetQuestions.length) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
moduleKey,
|
||||
suggestions: [],
|
||||
warnings: [{ code: "no_unanswered_questions", message: "No hay preguntas pendientes para sugerir en este modulo." }],
|
||||
});
|
||||
}
|
||||
|
||||
const answeredContext = moduleRecord.questions
|
||||
.map((question) => {
|
||||
const response = responseByQuestionId.get(question.id);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedOption = question.answerOptions.find((option) => option.id === response.answerOptionId) ?? null;
|
||||
const evidence = parseResponseEvidence(response.evidence);
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
prompt: question.prompt,
|
||||
selectedAnswerOptionId: selectedOption?.id ?? response.answerOptionId,
|
||||
selectedAnswerLabel: selectedOption?.label ?? "No identificada",
|
||||
evidenceNotes: evidence?.notes ?? "",
|
||||
evidenceLinks: evidence?.links ?? [],
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
const targetContext = targetQuestions.map((question) => ({
|
||||
questionId: question.id,
|
||||
prompt: question.prompt,
|
||||
helpText: question.helpText ?? "",
|
||||
options: question.answerOptions.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres un asistente experto en diagnostico organizacional para contratacion publica en Mexico.",
|
||||
"Genera sugerencias de respuesta para preguntas no contestadas sin inventar evidencia.",
|
||||
"Debes responder solamente JSON valido.",
|
||||
"El idioma de salida debe ser espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
`Modulo: ${moduleRecord.name} (${moduleRecord.key})`,
|
||||
"Respuestas ya contestadas por la organizacion:",
|
||||
JSON.stringify(answeredContext),
|
||||
"",
|
||||
"Preguntas objetivo a sugerir:",
|
||||
JSON.stringify(targetContext),
|
||||
"",
|
||||
"Instrucciones:",
|
||||
"- Para cada pregunta objetivo devuelve questionId y suggestedAnswerOptionId usando solo IDs listados en options.",
|
||||
"- rationale debe explicar por que esa opcion es la mas probable segun contexto.",
|
||||
"- missingEvidence debe listar evidencia que ayudaria a confirmar o corregir la sugerencia.",
|
||||
"- confidence debe ir de 0 a 1.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M1_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M1AiSuggestionResponseSchema,
|
||||
schemaName: "m1_question_suggestions",
|
||||
jsonSchema: M1AiSuggestionJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M1_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const targetQuestionById = new Map(
|
||||
targetQuestions.map((question) => [
|
||||
question.id,
|
||||
{
|
||||
id: question.id,
|
||||
optionIds: new Set(question.answerOptions.map((option) => option.id)),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const aiSuggestions = envelope.data?.suggestions ?? [];
|
||||
const normalizedSuggestions = aiSuggestions
|
||||
.map((item) => {
|
||||
const question = targetQuestionById.get(item.questionId);
|
||||
if (!question || !question.optionIds.has(item.suggestedAnswerOptionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is z.infer<typeof M1AiSuggestionItemSchema> => Boolean(item));
|
||||
|
||||
const persisted = await Promise.all(
|
||||
normalizedSuggestions.map(async (item) => {
|
||||
const saved = await storeAiSuggestionFromEnvelope({
|
||||
userId: session.userId,
|
||||
moduleKey: "M1",
|
||||
featureKey: "diagnostic_answer_suggestion",
|
||||
subjectType: "question",
|
||||
subjectId: item.questionId,
|
||||
inputForHash: {
|
||||
moduleKey,
|
||||
questionId: item.questionId,
|
||||
answeredContext,
|
||||
targetQuestion: targetContext.find((question) => question.questionId === item.questionId) ?? null,
|
||||
},
|
||||
envelope,
|
||||
responsePayload: item,
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
suggestionId: saved.suggestionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
moduleKey,
|
||||
suggestions: persisted.map((item) => ({
|
||||
questionId: item.questionId,
|
||||
suggestedAnswerOptionId: item.suggestedAnswerOptionId,
|
||||
rationale: item.rationale,
|
||||
missingEvidence: item.missingEvidence,
|
||||
confidence: toConfidenceNumber(item.confidence, envelope.confidence),
|
||||
suggestionId: item.suggestionId,
|
||||
})),
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
64
src/app/api/legal/cases/[id]/route.ts
Normal file
64
src/app/api/legal/cases/[id]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalCaseUpdateInputSchema } from "@/lib/legal/types";
|
||||
import { listLegalCasesForUser } from "@/lib/legal/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalCaseUpdateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const owned = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!owned) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
await prisma.legalCase.update({
|
||||
where: { id },
|
||||
data: {
|
||||
severity: input.severity,
|
||||
status: input.status,
|
||||
resolvedAt: input.resolvedAt === undefined ? undefined : input.resolvedAt ? new Date(input.resolvedAt) : null,
|
||||
description: input.description,
|
||||
amountAtRisk: input.amountAtRisk === undefined ? undefined : input.amountAtRisk ?? null,
|
||||
counterparty: input.counterparty,
|
||||
},
|
||||
});
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
const legalCase = cases.find((item) => item.id === id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, case: legalCase });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible actualizar el caso legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/legal/cases/route.ts
Normal file
62
src/app/api/legal/cases/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalCaseCreateInputSchema } from "@/lib/legal/types";
|
||||
import { listLegalCasesForUser } from "@/lib/legal/server";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
return NextResponse.json({ ok: true, cases });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalCaseCreateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de caso legal invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const created = await prisma.legalCase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
contractId: input.contractId ?? null,
|
||||
caseType: input.caseType,
|
||||
severity: input.severity,
|
||||
counterparty: input.counterparty,
|
||||
description: input.description,
|
||||
amountAtRisk: input.amountAtRisk ?? null,
|
||||
status: input.status ?? "OPEN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const cases = await listLegalCasesForUser(user.id);
|
||||
const legalCase = cases.find((item) => item.id === created.id) ?? null;
|
||||
|
||||
return NextResponse.json({ ok: true, case: legalCase });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible crear el caso legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
118
src/app/api/legal/diagnosis/answer/route.test.ts
Normal file
118
src/app/api/legal/diagnosis/answer/route.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, parseDiagnosisAnswersMock, evaluateDiagnosisMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
parseDiagnosisAnswersMock: vi.fn(),
|
||||
evaluateDiagnosisMock: vi.fn(),
|
||||
prismaMock: {
|
||||
legalDiagnosis: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contractRecord: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
legalCase: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/legal/diagnosis", () => ({
|
||||
parseDiagnosisAnswers: parseDiagnosisAnswersMock,
|
||||
evaluateDiagnosis: evaluateDiagnosisMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/legal/diagnosis/answer/route";
|
||||
|
||||
describe("M9 diagnosis contract continuity regression", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps persisted contractId when finalizing diagnosis into legal case", async () => {
|
||||
const now = new Date("2026-04-15T11:00:00.000Z");
|
||||
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.legalDiagnosis.findFirst.mockResolvedValue({
|
||||
id: "diag-1",
|
||||
totalSteps: 4,
|
||||
legalCaseId: null,
|
||||
answersJson: {
|
||||
contractId: "contract-1",
|
||||
counterparty: "Proveedor SA",
|
||||
description: "Incumplimiento de entregable",
|
||||
},
|
||||
});
|
||||
parseDiagnosisAnswersMock.mockReturnValue({ amountAtRisk: 5000 });
|
||||
evaluateDiagnosisMock.mockReturnValue({
|
||||
suggestedSeverity: "HIGH",
|
||||
suggestedCaseType: "CONTRACT_BREACH",
|
||||
});
|
||||
prismaMock.contractRecord.findFirst.mockResolvedValue({ id: "contract-1" });
|
||||
prismaMock.legalCase.create.mockResolvedValue({ id: "case-1" });
|
||||
prismaMock.legalDiagnosis.update.mockResolvedValue({
|
||||
id: "diag-1",
|
||||
legalCaseId: "case-1",
|
||||
stepIndex: 4,
|
||||
totalSteps: 4,
|
||||
answersJson: {
|
||||
contractId: "contract-1",
|
||||
},
|
||||
recommendedRouteJson: {
|
||||
suggestedSeverity: "HIGH",
|
||||
suggestedCaseType: "CONTRACT_BREACH",
|
||||
},
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost/api/legal/diagnosis/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
diagnosisId: "diag-1",
|
||||
stepIndex: 3,
|
||||
answers: {},
|
||||
finalize: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
diagnosis?: {
|
||||
legalCaseId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.diagnosis?.legalCaseId).toBe("case-1");
|
||||
expect(prismaMock.contractRecord.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "contract-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
expect(prismaMock.legalCase.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contractId: "contract-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
172
src/app/api/legal/diagnosis/answer/route.ts
Normal file
172
src/app/api/legal/diagnosis/answer/route.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis";
|
||||
import { LegalDiagnosisAnswerInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringOrEmpty(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function extractContractId(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDiagnosisAnswerInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de respuesta invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
logContinuityHandoff("m9_diagnosis_answer_received", {
|
||||
userId: user.id,
|
||||
diagnosisId: input.diagnosisId,
|
||||
finalize: Boolean(input.finalize),
|
||||
});
|
||||
|
||||
const diagnosis = await prisma.legalDiagnosis.findFirst({
|
||||
where: {
|
||||
id: input.diagnosisId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
answersJson: true,
|
||||
totalSteps: true,
|
||||
legalCaseId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!diagnosis) {
|
||||
return NextResponse.json({ error: "Diagnostico no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentAnswers =
|
||||
diagnosis.answersJson && typeof diagnosis.answersJson === "object" && !Array.isArray(diagnosis.answersJson)
|
||||
? (diagnosis.answersJson as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const mergedAnswers = {
|
||||
...currentAnswers,
|
||||
...input.answers,
|
||||
};
|
||||
|
||||
const normalizedAnswers = parseDiagnosisAnswers(mergedAnswers);
|
||||
const recommendation = evaluateDiagnosis(normalizedAnswers);
|
||||
const nextStep = input.finalize ? diagnosis.totalSteps : Math.min(diagnosis.totalSteps, input.stepIndex + 1);
|
||||
|
||||
let legalCaseId = diagnosis.legalCaseId;
|
||||
const persistedContractId = extractContractId(mergedAnswers.contractId);
|
||||
logContinuityHandoff("m9_diagnosis_contract_context", {
|
||||
userId: user.id,
|
||||
diagnosisId: diagnosis.id,
|
||||
persistedContractId,
|
||||
hasLegalCaseAlready: Boolean(legalCaseId),
|
||||
});
|
||||
|
||||
if (input.finalize && !legalCaseId) {
|
||||
if (persistedContractId) {
|
||||
const ownedContract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: persistedContractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownedContract) {
|
||||
return NextResponse.json({ error: "El contrato vinculado al diagnostico ya no esta disponible." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const createdCase = await prisma.legalCase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
contractId: persistedContractId,
|
||||
caseType: recommendation.suggestedCaseType,
|
||||
severity: recommendation.suggestedSeverity,
|
||||
counterparty: stringOrEmpty(mergedAnswers.counterparty) || "Contraparte por validar",
|
||||
description: stringOrEmpty(mergedAnswers.description) || "Caso generado desde diagnostico legal asistido.",
|
||||
amountAtRisk: normalizedAnswers.amountAtRisk,
|
||||
status: "OPEN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
legalCaseId = createdCase.id;
|
||||
logContinuityHandoff("m9_case_autocreated_from_diagnosis", {
|
||||
userId: user.id,
|
||||
diagnosisId: diagnosis.id,
|
||||
legalCaseId,
|
||||
contractId: persistedContractId,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.legalDiagnosis.update({
|
||||
where: {
|
||||
id: diagnosis.id,
|
||||
},
|
||||
data: {
|
||||
stepIndex: nextStep,
|
||||
answersJson: mergedAnswers as Prisma.InputJsonValue,
|
||||
recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue,
|
||||
legalCaseId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
legalCaseId: true,
|
||||
stepIndex: true,
|
||||
totalSteps: true,
|
||||
answersJson: true,
|
||||
recommendedRouteJson: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
diagnosis: {
|
||||
id: updated.id,
|
||||
legalCaseId: updated.legalCaseId,
|
||||
stepIndex: updated.stepIndex,
|
||||
totalSteps: updated.totalSteps,
|
||||
answers: updated.answersJson,
|
||||
recommendation: updated.recommendedRouteJson,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
105
src/app/api/legal/diagnosis/start/route.ts
Normal file
105
src/app/api/legal/diagnosis/start/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis";
|
||||
import { LegalDiagnosisStartInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDiagnosisStartInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos de inicio de diagnostico invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
logContinuityHandoff("m8_to_m9_diagnosis_start_requested", {
|
||||
userId: user.id,
|
||||
requestedContractId: input.contractId ?? null,
|
||||
});
|
||||
|
||||
let contractId: string | null = null;
|
||||
if (input.contractId) {
|
||||
const ownedContract = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
id: input.contractId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownedContract) {
|
||||
return NextResponse.json({ error: "Contrato no encontrado para iniciar diagnostico." }, { status: 404 });
|
||||
}
|
||||
|
||||
contractId = ownedContract.id;
|
||||
}
|
||||
|
||||
logContinuityHandoff("m8_to_m9_diagnosis_started", {
|
||||
userId: user.id,
|
||||
persistedContractId: contractId,
|
||||
});
|
||||
|
||||
const normalizedAnswers = parseDiagnosisAnswers({});
|
||||
const recommendation = evaluateDiagnosis(normalizedAnswers);
|
||||
const answersJson: Record<string, unknown> = {
|
||||
...normalizedAnswers,
|
||||
};
|
||||
|
||||
if (contractId) {
|
||||
answersJson.contractId = contractId;
|
||||
}
|
||||
|
||||
const diagnosis = await prisma.legalDiagnosis.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
answersJson: answersJson as Prisma.InputJsonValue,
|
||||
recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue,
|
||||
stepIndex: 1,
|
||||
totalSteps: 4,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stepIndex: true,
|
||||
totalSteps: true,
|
||||
answersJson: true,
|
||||
recommendedRouteJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
diagnosis: {
|
||||
id: diagnosis.id,
|
||||
stepIndex: diagnosis.stepIndex,
|
||||
totalSteps: diagnosis.totalSteps,
|
||||
answers: diagnosis.answersJson,
|
||||
contractId,
|
||||
recommendation: diagnosis.recommendedRouteJson,
|
||||
},
|
||||
});
|
||||
}
|
||||
26
src/app/api/legal/directory/route.ts
Normal file
26
src/app/api/legal/directory/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { LegalDirectoryFiltersSchema } from "@/lib/legal/types";
|
||||
import { listLegalDirectory } from "@/lib/legal/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = LegalDirectoryFiltersSchema.safeParse({
|
||||
jurisdictionLevel: searchParams.get("jurisdictionLevel") ?? undefined,
|
||||
stateCode: searchParams.get("stateCode") ?? undefined,
|
||||
q: searchParams.get("q") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const entities = await listLegalDirectory(parsed.data);
|
||||
return NextResponse.json({ ok: true, entities });
|
||||
}
|
||||
91
src/app/api/legal/documents/generate/route.ts
Normal file
91
src/app/api/legal/documents/generate/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateLegalDocument } from "@/lib/legal/ai-documents";
|
||||
import { LegalDocumentGenerateInputSchema } from "@/lib/legal/types";
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
const parsed = LegalDocumentGenerateInputSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Datos para generar escrito invalidos." }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
let legalCaseId: string | null = input.legalCaseId ?? null;
|
||||
if (legalCaseId) {
|
||||
const ownedCase = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id: legalCaseId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!ownedCase) {
|
||||
return NextResponse.json({ error: "Caso legal no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
legalCaseId = ownedCase.id;
|
||||
}
|
||||
|
||||
const generated = await generateLegalDocument({
|
||||
legalCaseId,
|
||||
caseType: input.caseType,
|
||||
severity: input.severity,
|
||||
templateKey: input.templateKey ?? null,
|
||||
counterparty: input.counterparty,
|
||||
companyName: input.companyName,
|
||||
amountAtRisk: input.amountAtRisk ?? null,
|
||||
description: input.description,
|
||||
objective: input.objective,
|
||||
});
|
||||
|
||||
const title = input.title?.trim() || generated.title;
|
||||
|
||||
const document = await prisma.legalDocument.create({
|
||||
data: {
|
||||
legalCaseId,
|
||||
userId: user.id,
|
||||
templateKey: input.templateKey ?? null,
|
||||
aiGenerated: generated.engine === "openai_json_object",
|
||||
title,
|
||||
content: generated.content,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
document: {
|
||||
id: document.id,
|
||||
legalCaseId: document.legalCaseId,
|
||||
templateKey: document.templateKey,
|
||||
aiGenerated: document.aiGenerated,
|
||||
title: document.title,
|
||||
content: document.content,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
},
|
||||
generation: {
|
||||
engine: generated.engine,
|
||||
model: generated.model,
|
||||
warnings: generated.warnings,
|
||||
usage: generated.usage,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "No fue posible generar el escrito legal." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/legal/escalation/[caseId]/route.ts
Normal file
90
src/app/api/legal/escalation/[caseId]/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getEscalationForCase } from "@/lib/legal/server";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ caseId: string }>;
|
||||
};
|
||||
|
||||
const VALID_STEP_KEYS = new Set([
|
||||
"formal_requirement",
|
||||
"administrative_conciliation",
|
||||
"oic_complaint",
|
||||
"sfp_or_state_challenge",
|
||||
"jurisdictional_path",
|
||||
]);
|
||||
|
||||
function parseJsonBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
const escalation = await getEscalationForCase(user.id, caseId);
|
||||
|
||||
if (!escalation) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, escalation });
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
const body = parseJsonBody(await request.json().catch(() => ({})));
|
||||
|
||||
const routeStepKey = typeof body.routeStepKey === "string" ? body.routeStepKey.trim() : "";
|
||||
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
|
||||
const completed = body.completed === false ? false : true;
|
||||
|
||||
if (!VALID_STEP_KEYS.has(routeStepKey)) {
|
||||
return NextResponse.json({ error: "routeStepKey invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const legalCase = await prisma.legalCase.findFirst({
|
||||
where: {
|
||||
id: caseId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!legalCase) {
|
||||
return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.legalEscalationStepLog.upsert({
|
||||
where: {
|
||||
legalCaseId_routeStepKey: {
|
||||
legalCaseId: caseId,
|
||||
routeStepKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
completedAt: completed ? new Date() : null,
|
||||
notes,
|
||||
},
|
||||
create: {
|
||||
legalCaseId: caseId,
|
||||
routeStepKey,
|
||||
completedAt: completed ? new Date() : null,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
|
||||
const escalation = await getEscalationForCase(user.id, caseId);
|
||||
return NextResponse.json({ ok: true, escalation });
|
||||
}
|
||||
14
src/app/api/legal/kpis/route.ts
Normal file
14
src/app/api/legal/kpis/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { getLegalKpisForUser } from "@/lib/legal/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const kpis = await getLegalKpisForUser(user.id);
|
||||
return NextResponse.json({ ok: true, kpis });
|
||||
}
|
||||
13
src/app/api/legal/templates/route.ts
Normal file
13
src/app/api/legal/templates/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { listLegalTemplates } from "@/lib/legal/ai-documents";
|
||||
|
||||
export async function GET() {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, templates: listLegalTemplates() });
|
||||
}
|
||||
33
src/app/api/licitations/ai/recommendations/route.ts
Normal file
33
src/app/api/licitations/ai/recommendations/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations";
|
||||
|
||||
function parseTop(value: string | null) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const profileId = url.searchParams.get("profileId") ?? url.searchParams.get("profile_id");
|
||||
const top = parseTop(url.searchParams.get("top"));
|
||||
const payload = await getAiEnhancedLicitationRecommendationsForUser(session.userId, {
|
||||
profileId,
|
||||
top,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
78
src/app/api/licitations/preferences/[id]/route.ts
Normal file
78
src/app/api/licitations/preferences/[id]/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Prisma, type LicitationReviewStatus } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { parseLicitationReviewStatus } from "@/lib/licitations/preferences";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const status = parseLicitationReviewStatus(body.status);
|
||||
|
||||
if (!status) {
|
||||
return NextResponse.json({ error: "Estatus invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const licitation = await prisma.licitation.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!licitation) {
|
||||
return NextResponse.json({ error: "Licitacion no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (status === "NEW") {
|
||||
await prisma.licitationUserPreference.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, status: "NEW" });
|
||||
}
|
||||
|
||||
const updated = await prisma.licitationUserPreference.upsert({
|
||||
where: {
|
||||
userId_licitationId: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: status as LicitationReviewStatus,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
licitationId: id,
|
||||
status: status as LicitationReviewStatus,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, status: updated.status });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de estado de oportunidades. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible actualizar el estado." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/normative-analysis/history/[id]/route.ts
Normal file
46
src/app/api/normative-analysis/history/[id]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const deleted = await prisma.normativeAnalysisHistory.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (deleted.count === 0) {
|
||||
return NextResponse.json({ error: "Analisis no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el analisis." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
98
src/app/api/normative-analysis/history/route.ts
Normal file
98
src/app/api/normative-analysis/history/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parsePositiveInt(value: string | null, fallback: number) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const take = Math.min(parsePositiveInt(url.searchParams.get("take"), 50), 100);
|
||||
const proposalId = url.searchParams.get("proposalId")?.trim() || null;
|
||||
let sourceLicitationId = url.searchParams.get("sourceId")?.trim() || null;
|
||||
|
||||
if (!sourceLicitationId && proposalId) {
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal?.sourceLicitationId) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
sourceLicitationId = proposal.sourceLicitationId;
|
||||
}
|
||||
|
||||
const history = await prisma.normativeAnalysisHistory.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(sourceLicitationId ? { sourceLicitationId } : {}),
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
items: history.map((entry) => toNormativeHistoryView(entry)),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible consultar el historial." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
612
src/app/api/normative-analysis/route.ts
Normal file
612
src/app/api/normative-analysis/route.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type NormativeConfidence, type NormativeDocumentType as PrismaNormativeDocumentType, type NormativeRiskLevel, Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationDocument, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze";
|
||||
import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024;
|
||||
const SOURCE_FETCH_TIMEOUT_MS = 45_000;
|
||||
const STORAGE_ROOT = path.resolve(process.cwd(), "storage");
|
||||
|
||||
function isPdfFile(file: File) {
|
||||
const extension = file.name.toLowerCase().endsWith(".pdf");
|
||||
const mimeType = file.type === "application/pdf";
|
||||
return extension || mimeType;
|
||||
}
|
||||
|
||||
function hasPdfSignature(buffer: Buffer) {
|
||||
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
|
||||
}
|
||||
|
||||
function guessFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "";
|
||||
const decoded = decodeURIComponent(lastSegment).trim();
|
||||
if (!decoded) {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
|
||||
return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`;
|
||||
} catch {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSourcePdf(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SOURCE_FETCH_FAILED_${response.status}`);
|
||||
}
|
||||
|
||||
const headerLength = response.headers.get("content-length");
|
||||
const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0;
|
||||
if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("SOURCE_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type LicitationSourcePdf = {
|
||||
fileBuffer: Buffer;
|
||||
fileName: string;
|
||||
linkedLicitationId: string;
|
||||
issuingEntity: string | null;
|
||||
};
|
||||
|
||||
type SourcePdfCandidate = {
|
||||
url: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
function buildSourcePdfCandidates(documentsRaw: unknown, rawSourceUrl: string | null) {
|
||||
const sourceDocuments = parseLicitationDocumentLinks(documentsRaw);
|
||||
const primaryPdf = pickPrimaryLicitationPdfDocument(sourceDocuments);
|
||||
const primaryAny = pickPrimaryLicitationDocument(sourceDocuments);
|
||||
const candidates: SourcePdfCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function pushCandidate(url: string | null | undefined, fileName: string | null | undefined) {
|
||||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!normalizedUrl || seen.has(normalizedUrl)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalizedUrl);
|
||||
candidates.push({
|
||||
url: normalizedUrl,
|
||||
fileName: (fileName?.trim() || guessFileNameFromUrl(normalizedUrl)),
|
||||
});
|
||||
}
|
||||
|
||||
pushCandidate(primaryPdf?.url, primaryPdf?.name);
|
||||
pushCandidate(primaryAny?.url, primaryAny?.name);
|
||||
|
||||
for (const document of sourceDocuments) {
|
||||
pushCandidate(document.url, document.name);
|
||||
}
|
||||
|
||||
if (rawSourceUrl) {
|
||||
const normalizedSourceUrl = rawSourceUrl.trim();
|
||||
const existingIndex = candidates.findIndex((item) => item.url === normalizedSourceUrl);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (isLikelyPdfUrl(normalizedSourceUrl) && existingIndex > 0) {
|
||||
const [entry] = candidates.splice(existingIndex, 1);
|
||||
candidates.unshift(entry);
|
||||
}
|
||||
} else if (isLikelyPdfUrl(normalizedSourceUrl)) {
|
||||
candidates.unshift({
|
||||
url: normalizedSourceUrl,
|
||||
fileName: guessFileNameFromUrl(normalizedSourceUrl),
|
||||
});
|
||||
} else {
|
||||
pushCandidate(normalizedSourceUrl, guessFileNameFromUrl(normalizedSourceUrl));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveLicitationSourcePdf(sourceLicitationId: string, currentIssuingEntity: string | null): Promise<LicitationSourcePdf> {
|
||||
const sourceLicitation = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: {
|
||||
id: true,
|
||||
supplierAwarded: true,
|
||||
documents: true,
|
||||
rawSourceUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceLicitation) {
|
||||
throw new Error("SOURCE_LICITATION_NOT_FOUND");
|
||||
}
|
||||
|
||||
const candidates = buildSourcePdfCandidates(sourceLicitation.documents, sourceLicitation.rawSourceUrl);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("SOURCE_LICITATION_NO_PDF");
|
||||
}
|
||||
|
||||
let sawSourceTooLarge = false;
|
||||
let sawSourceTimeout = false;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const fileBuffer = await fetchSourcePdf(candidate.url);
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
fileBuffer,
|
||||
fileName: candidate.fileName,
|
||||
linkedLicitationId: sourceLicitation.id,
|
||||
issuingEntity: currentIssuingEntity || sourceLicitation.supplierAwarded?.trim() || null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "SOURCE_PDF_TOO_LARGE") {
|
||||
sawSourceTooLarge = true;
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
sawSourceTimeout = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sawSourceTooLarge) {
|
||||
throw new Error("SOURCE_LICITATION_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
if (sawSourceTimeout) {
|
||||
throw new Error("SOURCE_LICITATION_PDF_TIMEOUT");
|
||||
}
|
||||
|
||||
throw new Error("SOURCE_LICITATION_NO_VALID_PDF");
|
||||
}
|
||||
|
||||
type ProposalDocumentSource = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
function isPdfProposalDocument(document: ProposalDocumentSource) {
|
||||
return document.mimeType === "application/pdf" || document.fileName.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function pickPrimaryProposalPdfDocument(documents: ProposalDocumentSource[]) {
|
||||
return documents.find((document) => isPdfProposalDocument(document)) ?? null;
|
||||
}
|
||||
|
||||
async function readStoredProposalPdf(filePath: string) {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||
|
||||
if (!absolutePath.startsWith(`${STORAGE_ROOT}${path.sep}`)) {
|
||||
throw new Error("PROPOSAL_PDF_INVALID_PATH");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(absolutePath);
|
||||
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("PROPOSAL_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("PROPOSAL_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
throw new Error("PROPOSAL_PDF_NOT_FOUND");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDocumentType(value: unknown): NormativeDocumentType {
|
||||
if (typeof value !== "string") {
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType {
|
||||
if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel {
|
||||
if (value === "alto") {
|
||||
return "ALTO";
|
||||
}
|
||||
|
||||
if (value === "medio") {
|
||||
return "MEDIO";
|
||||
}
|
||||
|
||||
return "BAJO";
|
||||
}
|
||||
|
||||
function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence {
|
||||
if (value === "high") {
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
if (value === "medium") {
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
return "LOW";
|
||||
}
|
||||
|
||||
function mapAnalysisError(error: unknown) {
|
||||
if (error instanceof PdfEncryptedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfUnreadableError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible leer el PDF. Verifica que el archivo no este danado.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrUnavailableError) {
|
||||
return {
|
||||
status: 503,
|
||||
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfNoTextDetectedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto util en el PDF para analizar.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrFailedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible analizar el PDF.",
|
||||
code: "NORMATIVE_ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentType = parseDocumentType(formData.get("documentType"));
|
||||
const issuingEntityRaw = formData.get("issuingEntity");
|
||||
let issuingEntity = typeof issuingEntityRaw === "string" ? issuingEntityRaw.trim() || null : null;
|
||||
const sourceLicitationIdRaw = formData.get("sourceLicitationId");
|
||||
const sourceLicitationId = typeof sourceLicitationIdRaw === "string" ? sourceLicitationIdRaw.trim() || null : null;
|
||||
const sourceProposalIdRaw = formData.get("sourceProposalId");
|
||||
const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() || null : null;
|
||||
const fileInput = formData.get("file");
|
||||
const uploadedFile = fileInput instanceof File && fileInput.size > 0 ? fileInput : null;
|
||||
const useSourceDocument = String(formData.get("useSourceDocument") ?? "") === "1";
|
||||
|
||||
try {
|
||||
let fileName = "";
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let linkedLicitationId: string | null = null;
|
||||
|
||||
if (uploadedFile) {
|
||||
if (uploadedFile.size > MAX_NORMATIVE_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isPdfFile(uploadedFile)) {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 });
|
||||
}
|
||||
|
||||
fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
fileName = uploadedFile.name;
|
||||
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
|
||||
}
|
||||
} else if (sourceProposalId && useSourceDocument) {
|
||||
const sourceProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: sourceProposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
issuingEntity: true,
|
||||
sourceLicitationId: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceProposal) {
|
||||
return NextResponse.json({ error: "No se encontro la propuesta vinculada de Modulo 5." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!issuingEntity) {
|
||||
issuingEntity = sourceProposal.issuingEntity?.trim() || null;
|
||||
}
|
||||
|
||||
linkedLicitationId = sourceProposal.sourceLicitationId ?? null;
|
||||
const proposalPdf = pickPrimaryProposalPdfDocument(sourceProposal.documents);
|
||||
|
||||
if (proposalPdf) {
|
||||
fileBuffer = await readStoredProposalPdf(proposalPdf.filePath);
|
||||
fileName = proposalPdf.fileName;
|
||||
|
||||
if (!hasPdfSignature(fileBuffer)) {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 no tiene una firma valida." }, { status: 422 });
|
||||
}
|
||||
} else if (sourceProposal.sourceLicitationId) {
|
||||
const licitationSource = await resolveLicitationSourcePdf(sourceProposal.sourceLicitationId, issuingEntity);
|
||||
fileBuffer = licitationSource.fileBuffer;
|
||||
fileName = licitationSource.fileName;
|
||||
linkedLicitationId = licitationSource.linkedLicitationId;
|
||||
issuingEntity = licitationSource.issuingEntity;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "La propuesta de Modulo 5 no tiene un PDF disponible para analisis automatico. Sube un PDF manualmente." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else if (sourceLicitationId && useSourceDocument) {
|
||||
const licitationSource = await resolveLicitationSourcePdf(sourceLicitationId, issuingEntity);
|
||||
fileBuffer = licitationSource.fileBuffer;
|
||||
fileName = licitationSource.fileName;
|
||||
linkedLicitationId = licitationSource.linkedLicitationId;
|
||||
issuingEntity = licitationSource.issuingEntity;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Debes adjuntar un PDF o vincular una propuesta de Modulo 5 / oportunidad de Modulo 3 para usar su documento principal." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!fileBuffer) {
|
||||
return NextResponse.json({ error: "No fue posible obtener un documento PDF para analizar." }, { status: 400 });
|
||||
}
|
||||
|
||||
const analyzed = await analyzePdf(fileBuffer);
|
||||
const aiAnalysis = await analyzeNormativeTextWithAi({
|
||||
text: analyzed.text,
|
||||
fileName,
|
||||
documentType,
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
});
|
||||
const result = aiAnalysis.result;
|
||||
const combinedWarnings = [
|
||||
...analyzed.warnings,
|
||||
...aiAnalysis.warnings,
|
||||
`Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`,
|
||||
];
|
||||
|
||||
if (!linkedLicitationId && sourceLicitationId) {
|
||||
const linkedLicitation = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: { id: true },
|
||||
});
|
||||
linkedLicitationId = linkedLicitation?.id ?? null;
|
||||
}
|
||||
|
||||
if (!linkedLicitationId && sourceProposalId) {
|
||||
const linkedProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: sourceProposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
});
|
||||
linkedLicitationId = linkedProposal?.sourceLicitationId ?? null;
|
||||
}
|
||||
|
||||
const created = await prisma.normativeAnalysisHistory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: linkedLicitationId,
|
||||
fileName,
|
||||
documentType: toPrismaDocumentType(documentType),
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT",
|
||||
numPages: analyzed.numPages,
|
||||
warnings: combinedWarnings,
|
||||
extractedChars: analyzed.text.length,
|
||||
confidence: toPrismaConfidence(result.confidence),
|
||||
viabilityScore: result.participationViability.score,
|
||||
riskLevel: toPrismaRiskLevel(result.risk.level),
|
||||
executiveSummary: result.executiveSummary,
|
||||
result,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
item: toNormativeHistoryView(created),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "SOURCE_LICITATION_NOT_FOUND") {
|
||||
return NextResponse.json({ error: "No se encontro la oportunidad vinculada de Modulo 3." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_NO_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "La oportunidad de Modulo 3 no tiene un PDF principal disponible para analisis automatico." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_NO_VALID_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "No se pudo obtener un PDF valido desde los enlaces de la oportunidad en Modulo 3." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "Los documentos vinculados de Modulo 3 exceden el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_PDF_TIMEOUT") {
|
||||
return NextResponse.json({ error: "Se excedio el tiempo al descargar documentos vinculados de Modulo 3." }, { status: 504 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_LICITATION_INVALID_PDF") {
|
||||
return NextResponse.json(
|
||||
{ error: "El documento principal de Modulo 3 no tiene una firma PDF valida." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_INVALID_PATH") {
|
||||
return NextResponse.json({ error: "El archivo de Modulo 5 no esta disponible para lectura segura." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_NOT_FOUND") {
|
||||
return NextResponse.json({ error: "No se encontro el PDF principal cargado en Modulo 5." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_EMPTY") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 esta vacio." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 5 excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
return NextResponse.json({ error: "Se excedio el tiempo al descargar el PDF principal de Modulo 3." }, { status: 504 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_PDF_TOO_LARGE") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 3 excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error.message === "SOURCE_PDF_EMPTY") {
|
||||
return NextResponse.json({ error: "El PDF principal de Modulo 3 esta vacio." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (error.message.startsWith("SOURCE_FETCH_FAILED_")) {
|
||||
return NextResponse.json({ error: "No fue posible descargar el PDF principal desde la fuente de Modulo 3." }, { status: 422 });
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = mapAnalysisError(error);
|
||||
return NextResponse.json({ error: mapped.error, code: mapped.code }, { status: mapped.status });
|
||||
}
|
||||
}
|
||||
50
src/app/api/payments/checkout/return/route.ts
Normal file
50
src/app/api/payments/checkout/return/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildAppUrl } from "@/lib/http/url";
|
||||
import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function pickFirst(url: URL, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = url.searchParams.get(key)?.trim();
|
||||
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const result = requestUrl.searchParams.get("result")?.trim() || "pending";
|
||||
const paymentId = pickFirst(requestUrl, ["payment_id", "collection_id"]);
|
||||
const externalReference = pickFirst(requestUrl, ["external_reference"]);
|
||||
const fallbackStatus = pickFirst(requestUrl, ["status", "collection_status"]) || result;
|
||||
const merchantOrderId = pickFirst(requestUrl, ["merchant_order_id"]);
|
||||
|
||||
if (paymentId && externalReference && hasMercadoPagoConfig()) {
|
||||
try {
|
||||
const payment = await fetchMercadoPaymentById(paymentId);
|
||||
const resolvedExternalReference = payment.external_reference || externalReference;
|
||||
|
||||
await syncPurchaseFromPayment(prisma, {
|
||||
externalReference: resolvedExternalReference,
|
||||
paymentId,
|
||||
paymentStatus: payment.status,
|
||||
mercadoOrderId: payment.order?.id || merchantOrderId || null,
|
||||
rawPayload: payment,
|
||||
});
|
||||
} catch {
|
||||
// Callback reconciliation is best-effort; webhook still updates asynchronously.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
buildAppUrl(request, "/dashboard", {
|
||||
mp_result: result,
|
||||
mp_payment_status: fallbackStatus,
|
||||
}),
|
||||
);
|
||||
}
|
||||
104
src/app/api/payments/checkout/route.ts
Normal file
104
src/app/api/payments/checkout/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionPayload } from "@/lib/auth/session";
|
||||
import { buildAppUrl, getRequestOrigin } from "@/lib/http/url";
|
||||
import { createMercadoPreference, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { getModulePlanByUiKey } from "@/lib/payments/plans";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function redirectToDashboard(request: Request, params: Record<string, string>) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/dashboard", params));
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSessionPayload();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" }));
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const planUiKey = requestUrl.searchParams.get("plan")?.trim() || "";
|
||||
const selectedPlan = getModulePlanByUiKey(planUiKey);
|
||||
|
||||
if (!selectedPlan) {
|
||||
return redirectToDashboard(request, { mp_error: "invalid_plan" });
|
||||
}
|
||||
|
||||
if (!hasMercadoPagoConfig()) {
|
||||
return redirectToDashboard(request, { mp_error: "missing_mp_config" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" }));
|
||||
}
|
||||
|
||||
const externalReference = `${selectedPlan.key}_${user.id}_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
||||
const origin = getRequestOrigin(request);
|
||||
const returnBase = `${origin}/api/payments/checkout/return`;
|
||||
|
||||
const createdPurchase = await prisma.modulePlanPurchase.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
planKey: selectedPlan.key,
|
||||
amount: selectedPlan.monthlyPrice,
|
||||
currency: selectedPlan.currency,
|
||||
externalReference,
|
||||
requestJson: {
|
||||
uiPlan: selectedPlan.uiKey,
|
||||
name: selectedPlan.name,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const preference = await createMercadoPreference({
|
||||
externalReference,
|
||||
payerEmail: user.email,
|
||||
items: [
|
||||
{
|
||||
id: selectedPlan.uiKey,
|
||||
title: `${selectedPlan.name} Modulos ${selectedPlan.moduleStart}-${selectedPlan.moduleEnd}`,
|
||||
quantity: 1,
|
||||
unit_price: selectedPlan.monthlyPrice,
|
||||
currency_id: selectedPlan.currency,
|
||||
},
|
||||
],
|
||||
successUrl: `${returnBase}?result=success`,
|
||||
failureUrl: `${returnBase}?result=failure`,
|
||||
pendingUrl: `${returnBase}?result=pending`,
|
||||
notificationUrl: `${origin}/api/payments/mercadopago/webhook`,
|
||||
});
|
||||
|
||||
const checkoutUrl = preference.initPoint || preference.sandboxInitPoint;
|
||||
|
||||
await prisma.modulePlanPurchase.update({
|
||||
where: { id: createdPurchase.id },
|
||||
data: {
|
||||
mercadoPreferenceId: preference.id || null,
|
||||
checkoutUrl: checkoutUrl || null,
|
||||
responseJson: preference.rawPayload as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkoutUrl) {
|
||||
return redirectToDashboard(request, { mp_error: "missing_checkout_url" });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(checkoutUrl);
|
||||
} catch {
|
||||
return redirectToDashboard(request, { mp_error: "checkout_create_failed" });
|
||||
}
|
||||
}
|
||||
66
src/app/api/payments/mercadopago/webhook/route.ts
Normal file
66
src/app/api/payments/mercadopago/webhook/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseBody(body: unknown) {
|
||||
return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseWebhookPaymentId(url: URL, body: Record<string, unknown>) {
|
||||
const fromQuery = url.searchParams.get("data.id")?.trim() || url.searchParams.get("id")?.trim() || "";
|
||||
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
const data = typeof body.data === "object" && body.data !== null ? (body.data as Record<string, unknown>) : null;
|
||||
return typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
|
||||
}
|
||||
|
||||
async function processWebhook(request: Request, body: Record<string, unknown>) {
|
||||
if (!hasMercadoPagoConfig()) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const eventType = (typeof body.type === "string" ? body.type : requestUrl.searchParams.get("type") || requestUrl.searchParams.get("topic") || "").toLowerCase();
|
||||
const paymentId = parseWebhookPaymentId(requestUrl, body);
|
||||
|
||||
if (eventType && eventType !== "payment") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (!paymentId) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const payment = await fetchMercadoPaymentById(paymentId);
|
||||
|
||||
if (!payment.external_reference) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await syncPurchaseFromPayment(prisma, {
|
||||
externalReference: payment.external_reference,
|
||||
paymentId,
|
||||
paymentStatus: payment.status,
|
||||
mercadoOrderId: payment.order?.id || null,
|
||||
rawPayload: payment,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = parseBody(await request.json().catch(() => ({})));
|
||||
return processWebhook(request, body);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return processWebhook(request, {});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, readStoredProposalDocumentMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
readStoredProposalDocumentMock: vi.fn(),
|
||||
prismaMock: {
|
||||
proposalDocument: {
|
||||
findFirst: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/proposals/storage", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/proposals/storage")>("@/lib/proposals/storage");
|
||||
return {
|
||||
...actual,
|
||||
readStoredProposalDocument: readStoredProposalDocumentMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/proposals/[id]/documents/[documentId]/route";
|
||||
|
||||
describe("proposal document download API security", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 for non-owned or missing proposal document", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposalDocument.findFirst.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "p1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toContain("Documento no encontrado");
|
||||
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when storage path is invalid", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposalDocument.findFirst.mockResolvedValue({
|
||||
id: "d1",
|
||||
fileName: "propuesta.pdf",
|
||||
filePath: "../outside.pdf",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
readStoredProposalDocumentMock.mockRejectedValue(new Error("PROPOSAL_DOCUMENT_INVALID_PATH"));
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), {
|
||||
params: Promise.resolve({ id: "p1", documentId: "d1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("Ruta de almacenamiento invalida");
|
||||
});
|
||||
});
|
||||
145
src/app/api/proposals/[id]/documents/[documentId]/route.ts
Normal file
145
src/app/api/proposals/[id]/documents/[documentId]/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readStoredProposalDocument, removeStoredProposalDocument } from "@/lib/proposals/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function safeContentDispositionFilename(value: string) {
|
||||
const normalized = value.trim().replace(/[\r\n"]/g, "_");
|
||||
return normalized || "documento";
|
||||
}
|
||||
|
||||
function mapDocumentReadError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_NOT_FOUND") {
|
||||
return { status: 404, message: "Archivo no encontrado en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_INVALID_PATH") {
|
||||
return { status: 400, message: "Ruta de almacenamiento invalida." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_EMPTY") {
|
||||
return { status: 400, message: "Archivo vacio en almacenamiento." };
|
||||
}
|
||||
|
||||
if (error.message === "PROPOSAL_DOCUMENT_TOO_LARGE") {
|
||||
return { status: 400, message: "Archivo excede el limite permitido." };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getOwnedProposalDocument(params: { userId: string; proposalId: string; documentId: string }) {
|
||||
return prisma.proposalDocument.findFirst({
|
||||
where: {
|
||||
id: params.documentId,
|
||||
proposalId: params.proposalId,
|
||||
userId: params.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await getOwnedProposalDocument({
|
||||
userId: user.id,
|
||||
proposalId: id,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = await readStoredProposalDocument(document.filePath);
|
||||
} catch (error) {
|
||||
const mapped = mapDocumentReadError(error);
|
||||
if (mapped) {
|
||||
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encodedFilename = encodeURIComponent(document.fileName);
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, documentId } = await context.params;
|
||||
|
||||
const document = await getOwnedProposalDocument({
|
||||
userId: user.id,
|
||||
proposalId: id,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
await removeStoredProposalDocument(document.filePath);
|
||||
await prisma.proposalDocument.delete({ where: { id: document.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
170
src/app/api/proposals/[id]/documents/route.ts
Normal file
170
src/app/api/proposals/[id]/documents/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
MAX_PROPOSAL_DOCUMENT_BYTES,
|
||||
isAllowedProposalMimeType,
|
||||
storeProposalDocument,
|
||||
} from "@/lib/proposals/storage";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Archivo requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size <= 0) {
|
||||
return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_PROPOSAL_DOCUMENT_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isAllowedProposalMimeType(file.type)) {
|
||||
return NextResponse.json({ error: "Tipo de archivo no permitido (PDF, DOC, DOCX, XLSX, JPG o PNG)." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
const stored = await storeProposalDocument(user.id, proposal.id, file.name, file.type, fileBuffer);
|
||||
|
||||
await prisma.proposalDocument.create({
|
||||
data: {
|
||||
proposalId: proposal.id,
|
||||
userId: user.id,
|
||||
fileName: stored.fileName,
|
||||
storedFileName: stored.storedFileName,
|
||||
filePath: stored.filePath,
|
||||
mimeType: stored.mimeType,
|
||||
sizeBytes: stored.sizeBytes,
|
||||
checksumSha256: stored.checksumSha256,
|
||||
},
|
||||
});
|
||||
|
||||
if (proposal.status === "DRAFT") {
|
||||
await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.proposal.findUnique({
|
||||
where: { id: proposal.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(updated),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible subir el documento." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/proposals/[id]/pdf/[kind]/route.ts
Normal file
106
src/app/api/proposals/[id]/pdf/[kind]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildPdfFilename, generateProposalPdf, type ProposalPdfKind } from "@/lib/proposals/pdf";
|
||||
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseKind(value: string): ProposalPdfKind | null {
|
||||
if (value === "technical" || value === "economic" || value === "summary") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; kind: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, kind: kindParam } = await context.params;
|
||||
const kind = parseKind(kindParam);
|
||||
if (!kind) {
|
||||
return NextResponse.json({ error: "Tipo de PDF invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
updatedAt: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const workflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
if (!workflowParsed.success) {
|
||||
return NextResponse.json({ error: "No hay flujo persistido valido para generar PDF." }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = await generateProposalPdf({
|
||||
kind,
|
||||
proposal: {
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
currentStep: proposal.currentStep,
|
||||
completionPercent: proposal.completionPercent,
|
||||
updatedAtIso: proposal.updatedAt.toISOString(),
|
||||
},
|
||||
workflow: workflowParsed.data,
|
||||
});
|
||||
|
||||
const filename = buildPdfFilename({
|
||||
kind,
|
||||
title: proposal.title,
|
||||
});
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No fue posible generar el PDF. Verifica que Playwright y Chromium esten disponibles en el entorno.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
262
src/app/api/proposals/[id]/route.ts
Normal file
262
src/app/api/proposals/[id]/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus, parseProposalStatus } from "@/lib/proposals/status";
|
||||
import { removeStoredProposalDocument } from "@/lib/proposals/storage";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
filePath?: string;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(proposal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const title = parseString(body.title);
|
||||
const issuingEntity = parseString(body.issuingEntity);
|
||||
const summary = parseString(body.summary);
|
||||
const nextStatus = parseProposalStatus(body.status);
|
||||
|
||||
if (body.status != null && !nextStatus) {
|
||||
return NextResponse.json({ error: "Estatus invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (nextStatus && !canTransitionProposalStatus(proposal.status, nextStatus)) {
|
||||
return NextResponse.json({ error: "La transicion de estatus no es valida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
...(title ? { title } : {}),
|
||||
...(issuingEntity ? { issuingEntity } : {}),
|
||||
...(summary || body.summary === "" ? { summary } : {}),
|
||||
...(nextStatus ? { status: nextStatus } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(updated),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible actualizar la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
documents: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
for (const document of proposal.documents) {
|
||||
await removeStoredProposalDocument(document.filePath);
|
||||
}
|
||||
|
||||
await prisma.proposal.delete({ where: { id: proposal.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible eliminar la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
61
src/app/api/proposals/[id]/workflow/extract/route.test.ts
Normal file
61
src/app/api/proposals/[id]/workflow/extract/route.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { requireAdminApiUserMock, prismaMock } = vi.hoisted(() => ({
|
||||
requireAdminApiUserMock: vi.fn(),
|
||||
prismaMock: {
|
||||
licitation: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
normativeAnalysisHistory: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
proposal: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/admin", () => ({
|
||||
requireAdminApiUser: requireAdminApiUserMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/proposals/[id]/workflow/extract/route";
|
||||
|
||||
describe("M4 -> M5 extract continuity regression", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("blocks extraction when proposal has no M4 source and no uploaded PDF", async () => {
|
||||
requireAdminApiUserMock.mockResolvedValue({ id: "user-1" });
|
||||
prismaMock.proposal.findFirst.mockResolvedValue({
|
||||
id: "proposal-1",
|
||||
title: "Propuesta sin fuente",
|
||||
issuingEntity: "Entidad",
|
||||
summary: "Resumen",
|
||||
status: "DRAFT",
|
||||
sourceLicitationId: null,
|
||||
workflowDraft: null,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost/api/proposals/proposal-1/workflow/extract", {
|
||||
method: "POST",
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "proposal-1" }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toContain("no tiene fuente de licitacion");
|
||||
expect(prismaMock.normativeAnalysisHistory.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
740
src/app/api/proposals/[id]/workflow/extract/route.ts
Normal file
740
src/app/api/proposals/[id]/workflow/extract/route.ts
Normal file
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
type NormativeConfidence,
|
||||
type NormativeDocumentType as PrismaNormativeDocumentType,
|
||||
type NormativeRiskLevel,
|
||||
Prisma,
|
||||
} from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { evaluateSignaturePolicy } from "@/lib/compliance/signature-policy";
|
||||
import { parseLicitationDocumentLinks, rankLicitationDocumentsForBundle, type LicitationBundleDocumentKind } from "@/lib/licitations/documents";
|
||||
import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze";
|
||||
import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze";
|
||||
import { mergeNormativeBundleSources, type NormativeBundleSourceAnalysis } from "@/lib/normative-analysis/bundle";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { analyzePdf } from "@/lib/pdf/analyzePdf";
|
||||
import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { buildDefaultWorkflowDraft } from "@/lib/proposals/workflow-default";
|
||||
import { applyAiSeedsToWorkflow, buildMilestoneSeedsFromAnalysis, buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow";
|
||||
import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024;
|
||||
const SOURCE_FETCH_TIMEOUT_MS = 45_000;
|
||||
|
||||
type BundleCandidate = {
|
||||
sourceId: string;
|
||||
fileName: string;
|
||||
documentType: NormativeDocumentType;
|
||||
fileBuffer: Buffer;
|
||||
};
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseDocumentType(value: unknown): NormativeDocumentType {
|
||||
if (typeof value !== "string") {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function mapBundleKindToDocumentType(kind: LicitationBundleDocumentKind): NormativeDocumentType {
|
||||
if (kind === "BASES_LICITACION") {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
if (kind === "CONVOCATORIA") {
|
||||
return "CONVOCATORIA";
|
||||
}
|
||||
|
||||
if (kind === "ANEXOS") {
|
||||
return "ANEXOS";
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function inferDocumentTypeFromName(fileName: string): NormativeDocumentType {
|
||||
const lower = fileName
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
if (/\bbase(s)?\b|\bpliego\b|\bterminos\b/.test(lower)) {
|
||||
return "BASES_LICITACION";
|
||||
}
|
||||
|
||||
if (/\bconvocatoria\b|\binvitacion\b/.test(lower)) {
|
||||
return "CONVOCATORIA";
|
||||
}
|
||||
|
||||
if (/\banexo(s)?\b|\bformato(s)?\b/.test(lower)) {
|
||||
return "ANEXOS";
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function guessFileNameFromUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "";
|
||||
const decoded = decodeURIComponent(lastSegment).trim();
|
||||
if (!decoded) {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
|
||||
return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`;
|
||||
} catch {
|
||||
return "documento-principal.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType {
|
||||
if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "OTRO";
|
||||
}
|
||||
|
||||
function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel {
|
||||
if (value === "alto") {
|
||||
return "ALTO";
|
||||
}
|
||||
|
||||
if (value === "medio") {
|
||||
return "MEDIO";
|
||||
}
|
||||
|
||||
return "BAJO";
|
||||
}
|
||||
|
||||
function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence {
|
||||
if (value === "high") {
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
if (value === "medium") {
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
return "LOW";
|
||||
}
|
||||
|
||||
function hasPdfSignature(buffer: Buffer) {
|
||||
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
|
||||
}
|
||||
|
||||
async function fetchSourcePdf(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SOURCE_FETCH_FAILED_${response.status}`);
|
||||
}
|
||||
|
||||
const headerLength = response.headers.get("content-length");
|
||||
const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0;
|
||||
if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error("SOURCE_PDF_EMPTY");
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
|
||||
throw new Error("SOURCE_PDF_TOO_LARGE");
|
||||
}
|
||||
|
||||
if (!hasPdfSignature(buffer)) {
|
||||
throw new Error("SOURCE_PDF_INVALID");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDateCandidate(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toMilestoneSeeds(args: {
|
||||
openingDate: Date | null;
|
||||
closingDate: Date | null;
|
||||
eventDates: Prisma.JsonValue | null;
|
||||
}): WorkflowMilestoneSeed[] {
|
||||
const milestones: WorkflowMilestoneSeed[] = [];
|
||||
|
||||
if (args.openingDate) {
|
||||
milestones.push({
|
||||
id: "opening-date",
|
||||
title: "Apertura del proceso",
|
||||
dateIso: args.openingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.closingDate) {
|
||||
milestones.push({
|
||||
id: "closing-date",
|
||||
title: "Presentacion de propuestas",
|
||||
dateIso: args.closingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) {
|
||||
const entries = Object.entries(args.eventDates as Record<string, unknown>);
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const [key, value] = entries[index];
|
||||
const parsed = parseDateCandidate(value);
|
||||
milestones.push({
|
||||
id: `event-date-${index + 1}`,
|
||||
title: key || `Evento ${index + 1}`,
|
||||
dateIso: parsed ? parsed.toISOString() : null,
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return milestones;
|
||||
}
|
||||
|
||||
function mapAnalysisError(error: unknown) {
|
||||
if (error instanceof PdfEncryptedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfUnreadableError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible leer el PDF. Verifica que el archivo no este danado.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrUnavailableError) {
|
||||
return {
|
||||
status: 503,
|
||||
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof PdfNoTextDetectedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto util en el PDF para analizar.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof OcrFailedError) {
|
||||
return {
|
||||
status: 422,
|
||||
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 422,
|
||||
error: "No fue posible analizar el PDF.",
|
||||
code: "NORMATIVE_ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const linkedSource = proposal.sourceLicitationId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: proposal.sourceLicitationId },
|
||||
include: {
|
||||
municipality: {
|
||||
select: {
|
||||
stateName: true,
|
||||
municipalityName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const filesFromMulti = formData
|
||||
? formData.getAll("files").filter((item): item is File => item instanceof File && item.size > 0)
|
||||
: [];
|
||||
const maybeSingleFile = formData ? formData.get("file") : null;
|
||||
const singleFile = maybeSingleFile instanceof File && maybeSingleFile.size > 0 ? maybeSingleFile : null;
|
||||
const uploadedFiles = filesFromMulti.length > 0 ? filesFromMulti : singleFile ? [singleFile] : [];
|
||||
const documentType = parseDocumentType(formData?.get("documentType"));
|
||||
const issuingEntityFromRequest = typeof formData?.get("issuingEntity") === "string" ? String(formData?.get("issuingEntity")).trim() : "";
|
||||
const issuingEntity = issuingEntityFromRequest || proposal.issuingEntity || linkedSource?.supplierAwarded || null;
|
||||
const requestedDocumentTypes = formData
|
||||
? formData
|
||||
.getAll("documentTypes")
|
||||
.map((value) => parseDocumentType(value))
|
||||
: [];
|
||||
|
||||
logContinuityHandoff("m4_to_m5_extract_requested", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
uploadedFiles: uploadedFiles.length,
|
||||
});
|
||||
|
||||
let analysisResult: ReturnType<typeof toNormativeHistoryView>["result"] | null = null;
|
||||
let analysisMeta: {
|
||||
source: "module4_history" | "uploaded_pdf";
|
||||
engine: string | null;
|
||||
model: string | null;
|
||||
warnings: string[];
|
||||
historyId: string | null;
|
||||
bundle?: {
|
||||
sources: Array<{ sourceId: string; fileName: string; documentType: NormativeDocumentType; historyId: string | null }>;
|
||||
conflicts: Array<{ title: string; field: string; keptDocumentType: NormativeDocumentType; replacedDocumentType: NormativeDocumentType }>;
|
||||
};
|
||||
} = {
|
||||
source: "module4_history",
|
||||
engine: null,
|
||||
model: null,
|
||||
warnings: [],
|
||||
historyId: null,
|
||||
bundle: {
|
||||
sources: [],
|
||||
conflicts: [],
|
||||
},
|
||||
};
|
||||
|
||||
const bundleCandidates: BundleCandidate[] = [];
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
for (let index = 0; index < uploadedFiles.length; index += 1) {
|
||||
const file = uploadedFiles[index];
|
||||
if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") {
|
||||
return NextResponse.json({ error: "Solo se permiten archivos PDF para extraccion IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_NORMATIVE_PDF_BYTES) {
|
||||
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!hasPdfSignature(buffer)) {
|
||||
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const inferredType = inferDocumentTypeFromName(file.name);
|
||||
const resolvedType =
|
||||
requestedDocumentTypes[index] ??
|
||||
(uploadedFiles.length === 1 && documentType !== "OTRO" ? documentType : inferredType !== "OTRO" ? inferredType : "OTRO");
|
||||
|
||||
bundleCandidates.push({
|
||||
sourceId: `upload-${index + 1}`,
|
||||
fileName: file.name,
|
||||
fileBuffer: buffer,
|
||||
documentType: resolvedType,
|
||||
});
|
||||
}
|
||||
} else if (linkedSource) {
|
||||
const sourceDocuments = parseLicitationDocumentLinks(linkedSource.documents);
|
||||
const ranked = rankLicitationDocumentsForBundle(sourceDocuments, 1);
|
||||
const ordered = [...ranked.bases, ...ranked.convocatoria, ...ranked.anexos, ...ranked.extras];
|
||||
const rawSourceUrl = linkedSource.rawSourceUrl?.trim() ?? "";
|
||||
const candidateDocuments = [...ordered];
|
||||
|
||||
if (rawSourceUrl && !candidateDocuments.some((item) => item.url === rawSourceUrl)) {
|
||||
candidateDocuments.push({
|
||||
name: guessFileNameFromUrl(rawSourceUrl),
|
||||
url: rawSourceUrl,
|
||||
type: null,
|
||||
bundleKind: "OTRO",
|
||||
bundleScore: -10_000,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < candidateDocuments.length && index < 6; index += 1) {
|
||||
const document = candidateDocuments[index];
|
||||
try {
|
||||
const buffer = await fetchSourcePdf(document.url);
|
||||
bundleCandidates.push({
|
||||
sourceId: `source-${index + 1}`,
|
||||
fileName: document.name,
|
||||
fileBuffer: buffer,
|
||||
documentType: mapBundleKindToDocumentType(document.bundleKind),
|
||||
});
|
||||
} catch (error) {
|
||||
analysisMeta.warnings.push(error instanceof Error ? `No se pudo leer ${document.name}: ${error.message}` : `No se pudo leer ${document.name}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bundleCandidates.length > 0) {
|
||||
const bundleAnalyses: NormativeBundleSourceAnalysis[] = [];
|
||||
|
||||
for (const candidate of bundleCandidates.slice(0, 3)) {
|
||||
try {
|
||||
const analyzed = await analyzePdf(candidate.fileBuffer);
|
||||
const aiAnalysis = await analyzeNormativeTextWithAi({
|
||||
text: analyzed.text,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed,
|
||||
numPages: analyzed.numPages,
|
||||
warnings: analyzed.warnings,
|
||||
});
|
||||
|
||||
const combinedWarnings = [
|
||||
...analyzed.warnings,
|
||||
...aiAnalysis.warnings,
|
||||
`Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`,
|
||||
];
|
||||
|
||||
const createdHistory = await prisma.normativeAnalysisHistory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: linkedSource?.id ?? null,
|
||||
fileName: candidate.fileName,
|
||||
documentType: toPrismaDocumentType(candidate.documentType),
|
||||
issuingEntity,
|
||||
methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT",
|
||||
numPages: analyzed.numPages,
|
||||
warnings: combinedWarnings,
|
||||
extractedChars: analyzed.text.length,
|
||||
confidence: toPrismaConfidence(aiAnalysis.result.confidence),
|
||||
viabilityScore: aiAnalysis.result.participationViability.score,
|
||||
riskLevel: toPrismaRiskLevel(aiAnalysis.result.risk.level),
|
||||
executiveSummary: aiAnalysis.result.executiveSummary,
|
||||
result: aiAnalysis.result,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
bundleAnalyses.push({
|
||||
sourceId: candidate.sourceId,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
result: aiAnalysis.result,
|
||||
});
|
||||
|
||||
analysisMeta.source = "uploaded_pdf";
|
||||
analysisMeta.engine = analysisMeta.engine ?? aiAnalysis.engine;
|
||||
analysisMeta.model = analysisMeta.model ?? aiAnalysis.model;
|
||||
analysisMeta.historyId = analysisMeta.historyId ?? createdHistory.id;
|
||||
analysisMeta.warnings.push(...combinedWarnings);
|
||||
analysisMeta.bundle?.sources.push({
|
||||
sourceId: candidate.sourceId,
|
||||
fileName: candidate.fileName,
|
||||
documentType: candidate.documentType,
|
||||
historyId: createdHistory.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = mapAnalysisError(error);
|
||||
analysisMeta.warnings.push(`${candidate.fileName}: ${mapped.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (bundleAnalyses.length === 0) {
|
||||
return NextResponse.json({ error: "No fue posible analizar los documentos del bundle." }, { status: 422 });
|
||||
}
|
||||
|
||||
if (bundleAnalyses.length === 1) {
|
||||
analysisResult = bundleAnalyses[0]?.result ?? null;
|
||||
} else {
|
||||
const merged = mergeNormativeBundleSources(bundleAnalyses);
|
||||
analysisResult = merged.result;
|
||||
analysisMeta.bundle = {
|
||||
sources: merged.sources.map((item) => ({
|
||||
...item,
|
||||
historyId: analysisMeta.bundle?.sources.find((source) => source.sourceId === item.sourceId)?.historyId ?? null,
|
||||
})),
|
||||
conflicts: merged.conflicts.map((conflict) => ({
|
||||
title: conflict.title,
|
||||
field: conflict.field,
|
||||
keptDocumentType: conflict.keptDocumentType,
|
||||
replacedDocumentType: conflict.replacedDocumentType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (!proposal.sourceLicitationId) {
|
||||
logContinuityHandoff("m4_to_m5_extract_blocked_no_source_context", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "La propuesta no tiene fuente de licitacion vinculada de Modulo 4. Sube un PDF para extraer requisitos con IA." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const historyEntry = await prisma.normativeAnalysisHistory.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!historyEntry) {
|
||||
logContinuityHandoff("m4_to_m5_extract_blocked_no_history", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "No hay analisis previo de Modulo 4 vinculado. Sube un PDF para extraer requisitos con IA." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const historyView = toNormativeHistoryView(historyEntry);
|
||||
analysisResult = historyView.result;
|
||||
analysisMeta = {
|
||||
source: "module4_history",
|
||||
engine: "module4_history",
|
||||
model: null,
|
||||
warnings: historyView.warnings,
|
||||
historyId: historyView.id,
|
||||
bundle: {
|
||||
sources: [
|
||||
{
|
||||
sourceId: `history-${historyView.id}`,
|
||||
fileName: historyView.fileName,
|
||||
documentType: historyView.documentType,
|
||||
historyId: historyView.id,
|
||||
},
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!analysisResult) {
|
||||
return NextResponse.json({ error: "No se pudo recuperar resultado de analisis IA." }, { status: 400 });
|
||||
}
|
||||
|
||||
const requirementSeeds = buildRequirementSeeds(analysisResult);
|
||||
const technicalSectionSeeds = buildTechnicalSectionSeeds(analysisResult);
|
||||
const aiMilestoneSeeds = buildMilestoneSeedsFromAnalysis(analysisResult);
|
||||
const sourceMilestoneSeeds = linkedSource
|
||||
? toMilestoneSeeds({
|
||||
openingDate: linkedSource.openingDate,
|
||||
closingDate: linkedSource.closingDate,
|
||||
eventDates: linkedSource.eventDates,
|
||||
})
|
||||
: [];
|
||||
const milestoneSeeds = [...sourceMilestoneSeeds, ...aiMilestoneSeeds];
|
||||
const existingWorkflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
const defaultDraft = buildDefaultWorkflowDraft({
|
||||
proposal: {
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
},
|
||||
linkedSource: linkedSource
|
||||
? {
|
||||
title: linkedSource.title,
|
||||
description: linkedSource.description,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
procedureType: linkedSource.procedureType,
|
||||
stateName: linkedSource.municipality.stateName,
|
||||
municipalityName: linkedSource.municipality.municipalityName,
|
||||
sourceUrl: linkedSource.rawSourceUrl,
|
||||
milestones: milestoneSeeds,
|
||||
}
|
||||
: null,
|
||||
requirementSeeds,
|
||||
technicalSectionSeeds,
|
||||
});
|
||||
|
||||
const baseWorkflow = existingWorkflowParsed.success ? existingWorkflowParsed.data : defaultDraft;
|
||||
const mergedWorkflow = applyAiSeedsToWorkflow(baseWorkflow, {
|
||||
requirementSeeds,
|
||||
technicalSectionSeeds,
|
||||
milestoneSeeds,
|
||||
});
|
||||
const extractedDocumentType = analysisMeta.bundle?.sources[0]?.documentType ?? documentType;
|
||||
const signaturePolicy = evaluateSignaturePolicy({
|
||||
stateName: linkedSource?.municipality.stateName ?? null,
|
||||
municipalityName: linkedSource?.municipality.municipalityName ?? null,
|
||||
documentType: extractedDocumentType,
|
||||
});
|
||||
const workflow = {
|
||||
...mergedWorkflow,
|
||||
signatureCompliance: {
|
||||
...mergedWorkflow.signatureCompliance,
|
||||
policyStatus: signaturePolicy.policyStatus,
|
||||
policyName: signaturePolicy.policyName,
|
||||
jurisdictionLabel: signaturePolicy.jurisdictionLabel,
|
||||
sourceUrl: signaturePolicy.sourceUrl ?? "",
|
||||
minimumEvidence: signaturePolicy.evidenceRequired,
|
||||
notes: signaturePolicy.notes,
|
||||
},
|
||||
step: Math.max(2, mergedWorkflow.step),
|
||||
};
|
||||
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
|
||||
const nextStatus =
|
||||
proposal.status === "DRAFT" && completionPercent > 0 && canTransitionProposalStatus("DRAFT", "IN_PROGRESS") ? "IN_PROGRESS" : proposal.status;
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
workflowDraft: workflow as Prisma.InputJsonValue,
|
||||
currentStep: workflow.step,
|
||||
completionPercent,
|
||||
...(nextStatus !== proposal.status ? { status: nextStatus } : {}),
|
||||
...(gate.allReady ? {} : { readyForSubmissionAt: null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logContinuityHandoff("m4_to_m5_extract_completed", {
|
||||
userId: user.id,
|
||||
proposalId: proposal.id,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
analysisSource: analysisMeta.source,
|
||||
historyId: analysisMeta.historyId,
|
||||
bundleSources: analysisMeta.bundle?.sources.length ?? 0,
|
||||
bundleConflicts: analysisMeta.bundle?.conflicts.length ?? 0,
|
||||
completionPercent,
|
||||
currentStep: updated.currentStep,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
workflow,
|
||||
gate,
|
||||
analysis: analysisMeta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible extraer requisitos con IA." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
131
src/app/api/proposals/[id]/workflow/mark-ready/route.ts
Normal file
131
src/app/api/proposals/[id]/workflow/mark-ready/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (proposal.status === "ARCHIVED") {
|
||||
return NextResponse.json({ error: "No puedes marcar una propuesta archivada." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedWorkflow = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft);
|
||||
if (!parsedWorkflow.success) {
|
||||
return NextResponse.json({ error: "No hay flujo persistido valido para validar la propuesta." }, { status: 400 });
|
||||
}
|
||||
|
||||
const workflow = parsedWorkflow.data;
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
if (!gate.allReady) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No se cumplen todos los requisitos para marcar como Lista para Envio.",
|
||||
gate,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const persistedWorkflow = {
|
||||
...workflow,
|
||||
readyMarked: true,
|
||||
step: 6,
|
||||
};
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (proposal.status === "DRAFT") {
|
||||
if (!canTransitionProposalStatus("DRAFT", "IN_PROGRESS")) {
|
||||
throw new Error("Transicion invalida de estatus.");
|
||||
}
|
||||
|
||||
await tx.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: { status: "IN_PROGRESS" },
|
||||
});
|
||||
}
|
||||
|
||||
const current = await tx.proposal.findUnique({
|
||||
where: { id: proposal.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new Error("Propuesta no encontrada.");
|
||||
}
|
||||
|
||||
if (current.status !== "SUBMITTED" && !canTransitionProposalStatus(current.status, "SUBMITTED")) {
|
||||
throw new Error("Transicion invalida de estatus.");
|
||||
}
|
||||
|
||||
return tx.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
status: "SUBMITTED",
|
||||
workflowDraft: persistedWorkflow as Prisma.InputJsonValue,
|
||||
completionPercent,
|
||||
currentStep: 6,
|
||||
readyForSubmissionAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible marcar la propuesta como lista para envio." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
163
src/app/api/proposals/[id]/workflow/route.ts
Normal file
163
src/app/api/proposals/[id]/workflow/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Prisma, type ProposalStatus } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canTransitionProposalStatus } from "@/lib/proposals/status";
|
||||
import { ProposalWorkflowStateSchema, clampWorkflowStep, computeWorkflowCompletionPercent, computeWorkflowGate, type ProposalWorkflowState } from "@/lib/proposals/workflow-state";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseWorkflowState(value: unknown): ProposalWorkflowState | null {
|
||||
const parsed = ProposalWorkflowStateSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.data,
|
||||
step: clampWorkflowStep(parsed.data.step),
|
||||
};
|
||||
}
|
||||
|
||||
function nextStatusFromDraft(progressStatus: ProposalStatus, completionPercent: number): ProposalStatus {
|
||||
if (completionPercent <= 0 || progressStatus !== "DRAFT") {
|
||||
return progressStatus;
|
||||
}
|
||||
|
||||
return canTransitionProposalStatus(progressStatus, "IN_PROGRESS") ? "IN_PROGRESS" : progressStatus;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
workflowDraft: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const workflow = parseWorkflowState(proposal.workflowDraft);
|
||||
const gate = workflow ? computeWorkflowGate(workflow) : null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: proposal.id,
|
||||
status: proposal.status,
|
||||
currentStep: proposal.currentStep,
|
||||
completionPercent: proposal.completionPercent,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
workflow,
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible cargar el flujo de trabajo." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const workflow = parseWorkflowState(body.workflow);
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json({ error: "Workflow invalido." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 });
|
||||
}
|
||||
|
||||
const gate = computeWorkflowGate(workflow);
|
||||
const completionPercent = computeWorkflowCompletionPercent(workflow);
|
||||
const nextStatus = nextStatusFromDraft(proposal.status, completionPercent);
|
||||
|
||||
const updated = await prisma.proposal.update({
|
||||
where: { id: proposal.id },
|
||||
data: {
|
||||
workflowDraft: workflow as Prisma.InputJsonValue,
|
||||
currentStep: workflow.step,
|
||||
completionPercent,
|
||||
...(proposal.status !== nextStatus ? { status: nextStatus } : {}),
|
||||
...(gate.allReady ? {} : { readyForSubmissionAt: null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentStep: true,
|
||||
completionPercent: true,
|
||||
readyForSubmissionAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentStep: updated.currentStep,
|
||||
completionPercent: updated.completionPercent,
|
||||
readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null,
|
||||
},
|
||||
gate,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible guardar el flujo de trabajo." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
151
src/app/api/proposals/route.ts
Normal file
151
src/app/api/proposals/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listUserProposals } from "@/lib/proposals/server";
|
||||
|
||||
function isSchemaNotReadyError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
|
||||
}
|
||||
|
||||
function parseString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function toProposalPayload(
|
||||
proposal: {
|
||||
id: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
sourceLicitationId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
} & {
|
||||
currentStep?: number;
|
||||
completionPercent?: number;
|
||||
readyForSubmissionAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
currentStep: proposal.currentStep ?? 1,
|
||||
completionPercent: proposal.completionPercent ?? 0,
|
||||
readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
createdAt: proposal.createdAt.toISOString(),
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const proposals = await listUserProposals(user.id, q);
|
||||
return NextResponse.json({ ok: true, proposals });
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible obtener propuestas." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const title = parseString(body.title);
|
||||
const issuingEntity = parseString(body.issuingEntity);
|
||||
const summary = parseString(body.summary);
|
||||
const sourceLicitationIdRaw = parseString(body.sourceLicitationId);
|
||||
const sourceLicitationId = sourceLicitationIdRaw || null;
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "El titulo es requerido." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!issuingEntity) {
|
||||
return NextResponse.json({ error: "La entidad emisora es requerida." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proposal = await prisma.proposal.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title,
|
||||
issuingEntity,
|
||||
summary,
|
||||
sourceLicitationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
proposal: toProposalPayload(proposal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSchemaNotReadyError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "No fue posible crear la propuesta." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/regulations/official/route.ts
Normal file
81
src/app/api/regulations/official/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import {
|
||||
listOfficialNormativeSources,
|
||||
listOfficialNormativeSuggestions,
|
||||
suggestOfficialNormativeSource,
|
||||
} from "@/lib/compliance/regulations";
|
||||
import type { OfficialNormativeSourceView, OfficialNormativeSuggestionInput } from "@/lib/compliance/types";
|
||||
|
||||
function isValidSourceType(value: unknown): value is OfficialNormativeSourceView["sourceType"] {
|
||||
return value === "ley" || value === "reglamento" || value === "lineamiento" || value === "portal";
|
||||
}
|
||||
|
||||
function parseSuggestion(body: Record<string, unknown>): OfficialNormativeSuggestionInput | null {
|
||||
const stateCode = typeof body.stateCode === "string" ? body.stateCode.trim().toUpperCase() : "";
|
||||
const municipalityCode = typeof body.municipalityCode === "string" ? body.municipalityCode.trim().toUpperCase() : null;
|
||||
const authorityName = typeof body.authorityName === "string" ? body.authorityName.trim() : "";
|
||||
const title = typeof body.title === "string" ? body.title.trim() : "";
|
||||
const officialUrl = typeof body.officialUrl === "string" ? body.officialUrl.trim() : "";
|
||||
const sourceType = body.sourceType;
|
||||
const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
|
||||
|
||||
if (!stateCode || !authorityName || !title || !officialUrl || !isValidSourceType(sourceType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stateCode,
|
||||
municipalityCode,
|
||||
authorityName,
|
||||
title,
|
||||
officialUrl,
|
||||
sourceType,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const includeSuggestions = url.searchParams.get("includeSuggestions") === "1";
|
||||
const stateCode = url.searchParams.get("stateCode")?.trim() || null;
|
||||
const municipalityCode = url.searchParams.get("municipalityCode")?.trim() || null;
|
||||
const pilotOnly = url.searchParams.get("pilotOnly") !== "0";
|
||||
|
||||
const sources = await listOfficialNormativeSources({
|
||||
stateCode,
|
||||
municipalityCode,
|
||||
pilotOnly,
|
||||
});
|
||||
const suggestions = includeSuggestions ? await listOfficialNormativeSuggestions() : undefined;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sources,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const suggestion = parseSuggestion(body);
|
||||
|
||||
if (!suggestion) {
|
||||
return NextResponse.json({ error: "Datos invalidos para sugerencia normativa." }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await suggestOfficialNormativeSource(suggestion);
|
||||
return NextResponse.json({ ok: true, suggestion: created }, { status: 201 });
|
||||
}
|
||||
19
src/app/api/regulations/official/verify/route.ts
Normal file
19
src/app/api/regulations/official/verify/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { verifyOfficialNormativeSources } from "@/lib/compliance/regulations";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { sourceIds?: unknown };
|
||||
const sourceIds = Array.isArray(body.sourceIds)
|
||||
? body.sourceIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim())
|
||||
: undefined;
|
||||
|
||||
const results = await verifyOfficialNormativeSources(sourceIds);
|
||||
return NextResponse.json({ ok: true, results });
|
||||
}
|
||||
279
src/app/api/strategic-diagnostic/ai/insights/route.ts
Normal file
279
src/app/api/strategic-diagnostic/ai/insights/route.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApiUser } from "@/lib/auth/admin";
|
||||
import { callOpenAiJsonSchema } from "@/lib/ai/openai";
|
||||
import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions";
|
||||
import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/server";
|
||||
import { STRATEGIC_SECTION_KEYS, type StrategicSectionKey } from "@/lib/strategic-diagnostic/types";
|
||||
|
||||
const M2_PROMPT_VERSION = "m2_insights_v1";
|
||||
|
||||
const M2SectionKeySchema = z.enum(STRATEGIC_SECTION_KEYS);
|
||||
|
||||
const M2InsightResponseSchema = z.object({
|
||||
sectionGaps: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
gap: z.string().min(8).max(240),
|
||||
impact: z.string().min(8).max(600),
|
||||
urgency: z.enum(["alta", "media", "baja"]),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
priorityActions: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(8).max(220),
|
||||
description: z.string().min(8).max(900),
|
||||
priority: z.enum(["alta", "media", "baja"]),
|
||||
ownerSuggestion: z.string().min(3).max(120),
|
||||
targetDateSuggestion: z.string().min(4).max(40),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
suggestedEvidence: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
category: z.string().min(3).max(120),
|
||||
reason: z.string().min(8).max(600),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
suggestedFieldValues: z
|
||||
.array(
|
||||
z.object({
|
||||
sectionKey: M2SectionKeySchema,
|
||||
fieldPath: z.string().min(4).max(120),
|
||||
suggestedValue: z.string().min(1).max(500),
|
||||
rationale: z.string().min(8).max(700),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
});
|
||||
|
||||
const M2InsightJsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionGaps", "priorityActions", "suggestedEvidence", "suggestedFieldValues", "confidence"],
|
||||
properties: {
|
||||
sectionGaps: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "gap", "impact", "urgency"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
gap: { type: "string" },
|
||||
impact: { type: "string" },
|
||||
urgency: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
priorityActions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "description", "priority", "ownerSuggestion", "targetDateSuggestion"],
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["alta", "media", "baja"] },
|
||||
ownerSuggestion: { type: "string" },
|
||||
targetDateSuggestion: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedEvidence: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "category", "reason"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
category: { type: "string" },
|
||||
reason: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFieldValues: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["sectionKey", "fieldPath", "suggestedValue", "rationale"],
|
||||
properties: {
|
||||
sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS },
|
||||
fieldPath: { type: "string" },
|
||||
suggestedValue: { type: "string" },
|
||||
rationale: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
function isSectionKey(value: string): value is StrategicSectionKey {
|
||||
return (STRATEGIC_SECTION_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function normalizeEvidenceMetadata(value: unknown, fallback: Record<StrategicSectionKey, { count: number; categories: string[] }>) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const incoming = value as Record<string, unknown>;
|
||||
const normalized = { ...fallback };
|
||||
|
||||
for (const key of STRATEGIC_SECTION_KEYS) {
|
||||
const sectionPayload = incoming[key];
|
||||
if (!sectionPayload || typeof sectionPayload !== "object" || Array.isArray(sectionPayload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionRecord = sectionPayload as Record<string, unknown>;
|
||||
const countCandidate = Number(sectionRecord.count);
|
||||
const categories = Array.isArray(sectionRecord.categories)
|
||||
? sectionRecord.categories
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
normalized[key] = {
|
||||
count: Number.isFinite(countCandidate) && countCandidate >= 0 ? Math.floor(countCandidate) : normalized[key].count,
|
||||
categories: categories.length ? categories : normalized[key].categories,
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await requireAdminApiUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const snapshot = await getStrategicDiagnosticSnapshot(user.id);
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const data = (body.data as unknown) ?? snapshot.data;
|
||||
|
||||
const fallbackEvidenceMetadata = STRATEGIC_SECTION_KEYS.reduce(
|
||||
(accumulator, key) => {
|
||||
accumulator[key] = {
|
||||
count: snapshot.evidenceBySection[key].length,
|
||||
categories: Array.from(new Set(snapshot.evidenceBySection[key].map((item) => item.category))).slice(0, 20),
|
||||
};
|
||||
return accumulator;
|
||||
},
|
||||
{} as Record<StrategicSectionKey, { count: number; categories: string[] }>,
|
||||
);
|
||||
|
||||
const evidenceMetadata = normalizeEvidenceMetadata(body.evidenceMetadata, fallbackEvidenceMetadata);
|
||||
|
||||
const systemPrompt = [
|
||||
"Eres consultor senior de estrategia comercial para licitaciones publicas en Mexico.",
|
||||
"Analiza el diagnostico estrategico y prioriza acciones de alto impacto.",
|
||||
"No alteres puntajes deterministas: solo entrega recomendaciones asistidas.",
|
||||
"Responde exclusivamente JSON valido en espanol.",
|
||||
].join(" ");
|
||||
|
||||
const userPrompt = [
|
||||
"Diagnostico estrategico actual:",
|
||||
JSON.stringify({
|
||||
data,
|
||||
scores: snapshot.scores,
|
||||
evidenceMetadata,
|
||||
}),
|
||||
"",
|
||||
"Genera:",
|
||||
"- sectionGaps: brechas por seccion con impacto y urgencia.",
|
||||
"- priorityActions: acciones priorizadas con responsable sugerido y fecha objetivo.",
|
||||
"- suggestedEvidence: evidencia recomendada por seccion.",
|
||||
"- suggestedFieldValues: valores sugeridos para campos del formulario.",
|
||||
"",
|
||||
"Reglas para suggestedFieldValues:",
|
||||
"- sectionKey debe ser una seccion valida.",
|
||||
"- fieldPath debe usar notacion con punto (ej. technical.responseTimes).",
|
||||
"- suggestedValue debe ser texto aplicable directamente por el usuario.",
|
||||
].join("\n");
|
||||
|
||||
const envelope = await callOpenAiJsonSchema({
|
||||
promptVersion: M2_PROMPT_VERSION,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
outputSchema: M2InsightResponseSchema,
|
||||
schemaName: "m2_strategic_insights",
|
||||
jsonSchema: M2InsightJsonSchema as unknown as Record<string, unknown>,
|
||||
model: process.env.OPENAI_M2_MODEL?.trim() || undefined,
|
||||
});
|
||||
|
||||
const safeData =
|
||||
envelope.data ??
|
||||
({
|
||||
sectionGaps: [],
|
||||
priorityActions: [],
|
||||
suggestedEvidence: [],
|
||||
suggestedFieldValues: [],
|
||||
confidence: envelope.confidence ?? "low",
|
||||
} satisfies z.infer<typeof M2InsightResponseSchema>);
|
||||
|
||||
const filteredFieldValues = safeData.suggestedFieldValues.filter((item) => {
|
||||
if (!isSectionKey(item.sectionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.fieldPath.startsWith(`${item.sectionKey}.`);
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sectionGaps: safeData.sectionGaps,
|
||||
priorityActions: safeData.priorityActions,
|
||||
suggestedEvidence: safeData.suggestedEvidence,
|
||||
suggestedFieldValues: filteredFieldValues,
|
||||
confidence: safeData.confidence,
|
||||
};
|
||||
|
||||
const persisted = await storeAiSuggestionFromEnvelope({
|
||||
userId: user.id,
|
||||
moduleKey: "M2",
|
||||
featureKey: "strategic_insights",
|
||||
subjectType: "organization",
|
||||
subjectId: snapshot.organizationId,
|
||||
inputForHash: {
|
||||
organizationId: snapshot.organizationId,
|
||||
data,
|
||||
evidenceMetadata,
|
||||
scores: snapshot.scores,
|
||||
},
|
||||
envelope,
|
||||
responsePayload: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...payload,
|
||||
suggestionId: persisted.suggestionId,
|
||||
meta: {
|
||||
engine: envelope.engine,
|
||||
model: envelope.model,
|
||||
usage: envelope.usage,
|
||||
warnings: envelope.warnings,
|
||||
confidence: envelope.confidence,
|
||||
},
|
||||
});
|
||||
}
|
||||
118
src/app/candados/page.tsx
Normal file
118
src/app/candados/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NormativeAnalysisView } from "@/components/app/normative-analysis-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type CandadosPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function CandadosPage({ searchParams }: CandadosPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 6);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell title="Modulo 6: Candados" description="Acceso restringido al modulo premium.">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Necesitas un plan con acceso premium para ingresar al Modulo 6.</p>
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const proposalId = getParam(resolvedSearchParams, "proposalId").trim();
|
||||
const sourceIdFromQuery = getParam(resolvedSearchParams, "sourceId").trim();
|
||||
|
||||
const linkedProposal = proposalId
|
||||
? await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
sourceLicitationId: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const sourceId = (linkedProposal?.sourceLicitationId ?? sourceIdFromQuery) || null;
|
||||
const linkedSource = sourceId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: sourceId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const backHref = linkedProposal ? `/gestion-licitaciones/${encodeURIComponent(linkedProposal.id)}` : "/gestion-licitaciones";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 6: Candados"
|
||||
headerMode="module"
|
||||
headerBackHref={backHref}
|
||||
headerBackLabel="Volver a M5"
|
||||
headerNextHref="/compliance-alerts"
|
||||
headerNextLabel="M7: Cumplimiento"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Detector de Candados y Riesgos</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Modulo 6: detecta direccionamiento, favoritismo y candados en bases de licitacion</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NormativeAnalysisView
|
||||
linkedProposal={
|
||||
linkedProposal
|
||||
? {
|
||||
id: linkedProposal.id,
|
||||
title: linkedProposal.title,
|
||||
issuingEntity: linkedProposal.issuingEntity,
|
||||
}
|
||||
: null
|
||||
}
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoAnalyzeOnLoad={Boolean(linkedProposal || linkedSource)}
|
||||
moduleContext="candados"
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
66
src/app/compliance-alerts/page.tsx
Normal file
66
src/app/compliance-alerts/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { getM7DatasetForUser } from "@/lib/compliance/server";
|
||||
import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default async function ComplianceAlertsPage() {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 7);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 7: Alertas de Cumplimiento"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 7 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/gestion-licitaciones">
|
||||
<Button variant="secondary">Ir a Modulo 5</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const dataset = await getM7DatasetForUser(user.id);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 7: Alertas de Cumplimiento"
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerNextHref="/gestion-contratos"
|
||||
headerNextLabel="M8: Contratos"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 7: Alertas de Cumplimiento</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Monitorea plazos, riesgos, checklist y vigencia normativa desde una sola vista</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ComplianceAlertsView dataset={dataset} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { ModulePlanKey } from "@prisma/client";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { getActivePlanKeysForUser } from "@/lib/auth/module-access";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { DashboardMaturitySection } from "@/components/app/dashboard-maturity-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { hasMercadoPagoConfig } from "@/lib/payments/mercadopago";
|
||||
import { getModulePlanByUiKey } from "@/lib/payments/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { recomputeAssessmentResults } from "@/lib/scoring";
|
||||
import { getTalleresSnapshot } from "@/lib/talleres/server";
|
||||
@@ -57,6 +61,7 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M04",
|
||||
title: "Analisis Normativo",
|
||||
description: "Analiza las bases de licitacion que te interesen con IA.",
|
||||
href: "/normative-analysis",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -75,16 +80,19 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M05",
|
||||
title: "Gestion Integral de Licitaciones",
|
||||
description: "Herramienta completa para preparar, revisar y enviar propuestas con checklist.",
|
||||
href: "/gestion-licitaciones",
|
||||
},
|
||||
{
|
||||
id: "M06",
|
||||
title: "Detector de Candados y Riesgos",
|
||||
description: "Analisis automatico de bases para identificar requisitos excluyentes o riesgos.",
|
||||
href: "/candados",
|
||||
},
|
||||
{
|
||||
id: "M07",
|
||||
title: "Alertas de Cumplimiento",
|
||||
description: "Notificaciones proactivas sobre plazos, documentos vencidos y cambios normativos.",
|
||||
href: "/compliance-alerts",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -103,16 +111,19 @@ const planGroups: PlanGroup[] = [
|
||||
id: "M08",
|
||||
title: "Gestion Estrategica de Contratos",
|
||||
description: "Dashboard para administrar contratos activos, entregables y pagos.",
|
||||
href: "/gestion-contratos",
|
||||
},
|
||||
{
|
||||
id: "M09",
|
||||
title: "Proteccion Legal",
|
||||
description: "Guia legal y plantillas ante incumplimientos, retenciones o sanciones.",
|
||||
href: "/proteccion-legal",
|
||||
},
|
||||
{
|
||||
id: "M10",
|
||||
title: "Simulador de Auditorias",
|
||||
description: "Autoevaluacion que prepara a tu empresa para auditorias gubernamentales.",
|
||||
href: "/expediente-preventivo",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -165,11 +176,28 @@ function LockIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
type DashboardPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const snapshot = await recomputeAssessmentResults(user.id);
|
||||
const talleresSnapshot = await getTalleresSnapshot(user.id, { assessmentSnapshot: snapshot });
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const isAdminUser = isAdminIdentity(user.email, user.role);
|
||||
const activePlanKeys = isAdminUser
|
||||
? new Set<ModulePlanKey>(["PLAN_2_4", "PLAN_5_7", "PLAN_8_10"])
|
||||
: await getActivePlanKeysForUser(user.id);
|
||||
const hasPaidModulesAccess = activePlanKeys.size > 0;
|
||||
const paymentConfigured = hasMercadoPagoConfig();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const checkoutResult = getParam(resolvedSearchParams, "mp_result");
|
||||
const checkoutError = getParam(resolvedSearchParams, "mp_error");
|
||||
const roundedOverallScore = Math.round(snapshot.overallScore);
|
||||
const readinessLabel = getReadinessLabel(roundedOverallScore);
|
||||
const strongest = snapshot.strongestModule;
|
||||
@@ -212,11 +240,11 @@ export default async function DashboardPage() {
|
||||
<Card className="border-[#a8dbc8]">
|
||||
<CardContent className="py-8">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<span className="inline-flex rounded-full bg-[#24aa77] px-8 py-2 text-2xl font-semibold text-white">Nivel {readinessLabel}</span>
|
||||
<h2 className="mt-6 text-4xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-5xl">
|
||||
<span className="inline-flex rounded-full bg-[#24aa77] px-7 py-2 text-xl font-semibold text-white">Nivel {readinessLabel}</span>
|
||||
<h2 className="mt-6 text-3xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-4xl">
|
||||
Puntaje Global: {roundedOverallScore}%
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-3xl text-2xl leading-relaxed text-[#4d6285] md:text-[35px] md:leading-tight">
|
||||
<p className="mx-auto mt-4 max-w-3xl text-xl leading-relaxed text-[#4d6285] md:text-2xl">
|
||||
{getReadinessDescription(roundedOverallScore)}
|
||||
</p>
|
||||
|
||||
@@ -251,13 +279,29 @@ export default async function DashboardPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#1a2b48] [font-family:var(--font-display)] md:text-3xl">Modulos y planes</h2>
|
||||
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-20 por suscripcion.</p>
|
||||
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-10 por suscripcion.</p>
|
||||
</div>
|
||||
<span className={cn("inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm font-semibold", hasPaidModulesAccess ? "border-[#a9d8c8] bg-[#e8f6ef] text-[#1e8f67]" : "border-[#d7dceb] bg-[#eff3fb] text-[#5a6b88]")}>
|
||||
{hasPaidModulesAccess ? "Plan con acceso activo" : "Cuenta en plan gratuito"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{checkoutResult ? (
|
||||
<div className={cn("rounded-xl border px-4 py-3 text-sm", checkoutResult === "success" ? "border-[#a9d8c8] bg-[#ecf9f2] text-[#1f8b63]" : "border-[#e7d7ac] bg-[#fff8ea] text-[#8b6b1f]")}>
|
||||
{checkoutResult === "success"
|
||||
? "Mercado Pago reporto checkout exitoso. Estamos validando tu pago y activando el plan."
|
||||
: checkoutResult === "failure"
|
||||
? "El pago no se completo. Puedes intentarlo nuevamente."
|
||||
: "Tu pago esta pendiente de confirmacion. Te avisaremos cuando se active el plan."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checkoutError ? (
|
||||
<div className="rounded-xl border border-[#f1c7cc] bg-[#fff1f3] px-4 py-3 text-sm text-[#a03d4b]">
|
||||
No fue posible iniciar el checkout ({checkoutError}). Verifica la configuracion de Mercado Pago.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="border-[#a9d9c7] bg-[#f3fbf7]">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
@@ -275,6 +319,11 @@ export default async function DashboardPage() {
|
||||
|
||||
{planGroups.map((group) => (
|
||||
<article key={group.key} className={cn("rounded-2xl border p-3 md:p-4", group.frameClassName)}>
|
||||
{(() => {
|
||||
const plan = getModulePlanByUiKey(group.key);
|
||||
const planActive = plan ? activePlanKeys.has(plan.key) : false;
|
||||
return (
|
||||
<>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/60 bg-white/50 px-3 py-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("mt-1.5 h-3 w-3 rounded-full", group.dotClassName)} />
|
||||
@@ -288,13 +337,13 @@ export default async function DashboardPage() {
|
||||
|
||||
<span className={cn("inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-semibold", group.badgeClassName)}>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{hasPaidModulesAccess ? "Disponible" : "Bloqueado"}
|
||||
{planActive ? "Disponible" : "Bloqueado"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
{group.modules.map((moduleItem) => {
|
||||
const isLocked = !hasPaidModulesAccess || !moduleItem.href;
|
||||
const isLocked = !planActive || !moduleItem.href;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -335,12 +384,33 @@ export default async function DashboardPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!planActive ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/70 bg-white/55 px-3 py-3">
|
||||
<p className="text-sm text-[#5a6f90]">
|
||||
Desbloquea Modulos {plan?.moduleStart}-{plan?.moduleEnd} con {group.name}.
|
||||
</p>
|
||||
{paymentConfigured ? (
|
||||
<Link href={`/api/payments/checkout?plan=${encodeURIComponent(group.key)}`}>
|
||||
<Button size="sm" className="rounded-xl">
|
||||
Comprar {group.name}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-full border border-[#d8dfea] bg-[#eef2f8] px-3 py-1 text-xs font-semibold text-[#64738c]">
|
||||
Configura `MP_ACCESS_TOKEN`
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
))}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-[#d4dceb] bg-[#f8fbff] px-5 py-5 text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-[#607290]">Ruta de expansion</p>
|
||||
<p className="mt-2 text-base text-[#536a8e]">Modulos 11-20 se habilitaran en siguientes etapas de la plataforma.</p>
|
||||
<p className="mt-2 text-base text-[#536a8e]">Actualmente la plataforma contempla 10 modulos.</p>
|
||||
<Link href="/#planes" className="mt-4 inline-flex">
|
||||
<Button size="sm" className="rounded-xl bg-[#e1af1c] text-[#243351] hover:bg-[#cf9f16]">
|
||||
Ver todos los planes
|
||||
|
||||
89
src/app/expediente-preventivo/page.tsx
Normal file
89
src/app/expediente-preventivo/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import {
|
||||
getInstitutionalDossierForUser,
|
||||
getAuditKpisForUser,
|
||||
listAuditSimulationsForUser,
|
||||
} from "@/lib/audits/server";
|
||||
import { PreventiveDossierView } from "@/components/app/preventive-dossier-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { InstitutionalDossierLoadStrategy } from "@/lib/audits/types";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function ExpedientePreventivoPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 10);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 10: Expediente Preventivo"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 10 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/proteccion-legal">
|
||||
<Button variant="secondary">Ir a Modulo 9</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const strategy = (getParam(resolvedSearchParams, "strategy") === "snapshot" ? "snapshot" : "live") satisfies InstitutionalDossierLoadStrategy;
|
||||
|
||||
const [simulations, kpis, dossierState] = await Promise.all([
|
||||
listAuditSimulationsForUser(user.id),
|
||||
getAuditKpisForUser(user.id),
|
||||
getInstitutionalDossierForUser(user.id, strategy),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 10: Expediente Preventivo"
|
||||
headerMode="module"
|
||||
headerBackHref="/proteccion-legal"
|
||||
headerBackLabel="M9: Legal"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◫</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Expediente Preventivo</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Simula auditorias y consolida evidencia institucional desde M5, M8, M9 y M7</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PreventiveDossierView
|
||||
initialSimulations={simulations}
|
||||
initialKpis={kpis}
|
||||
initialDossier={dossierState.dossier}
|
||||
initialFreshness={dossierState.freshness}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
237
src/app/gestion-contratos/page.tsx
Normal file
237
src/app/gestion-contratos/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getContractsKpisForUser, listContractsForUser } from "@/lib/contracts/server";
|
||||
import {
|
||||
ensureProposalPdfLinkedToContract,
|
||||
getLatestProposalPdfSourceForUser,
|
||||
isPdfProposalDocument,
|
||||
} from "@/lib/contracts/proposal-continuity";
|
||||
import { ContractsManagementView } from "@/components/app/contracts-management-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
|
||||
console.info(
|
||||
"[continuity_handoff]",
|
||||
JSON.stringify({
|
||||
event,
|
||||
at: new Date().toISOString(),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function GestionContratosPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 8);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 8: Gestion Estrategica de Contratos"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 8 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/compliance-alerts">
|
||||
<Button variant="secondary">Ir a Modulo 7</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const proposalId = getParam(resolvedSearchParams, "proposalId") || null;
|
||||
const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1";
|
||||
|
||||
if (proposalId || shouldAutoCreate) {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_requested", {
|
||||
userId: user.id,
|
||||
proposalId,
|
||||
shouldAutoCreate,
|
||||
});
|
||||
}
|
||||
|
||||
if (proposalId && shouldAutoCreate) {
|
||||
const ownedProposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id: proposalId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (ownedProposal) {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_proposal_resolved", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
});
|
||||
|
||||
const existing = await prisma.contractRecord.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceProposalId: ownedProposal.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
let targetContractId = existing?.id ?? null;
|
||||
if (!targetContractId) {
|
||||
const created = await prisma.contractRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceProposalId: ownedProposal.id,
|
||||
title: ownedProposal.title,
|
||||
counterpartyEntity: ownedProposal.issuingEntity,
|
||||
contractType: "Desde propuesta adjudicada",
|
||||
description: ownedProposal.summary,
|
||||
status: "ACTIVE",
|
||||
currency: "MXN",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
targetContractId = created.id;
|
||||
}
|
||||
logContinuityHandoff("m5_to_m8_autocreate_contract_resolved", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
mode: existing ? "reuse" : "create",
|
||||
});
|
||||
|
||||
if (targetContractId) {
|
||||
try {
|
||||
const proposalPdfLookup = await getLatestProposalPdfSourceForUser(user.id, ownedProposal.id);
|
||||
logContinuityHandoff("m5_to_m8_autocreate_proposal_pdf_lookup", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
lookupStatus: proposalPdfLookup.status,
|
||||
});
|
||||
|
||||
if (proposalPdfLookup.status === "ok") {
|
||||
const linked = await ensureProposalPdfLinkedToContract({
|
||||
userId: user.id,
|
||||
contractId: targetContractId,
|
||||
source: proposalPdfLookup.source,
|
||||
});
|
||||
logContinuityHandoff("m5_to_m8_autocreate_pdf_linked", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
created: linked.created,
|
||||
documentId: linked.documentId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
logContinuityHandoff("m5_to_m8_autocreate_pdf_link_failed", {
|
||||
userId: user.id,
|
||||
proposalId: ownedProposal.id,
|
||||
contractId: targetContractId,
|
||||
});
|
||||
// Preserve autocreate flow even if continuity copy fails unexpectedly.
|
||||
}
|
||||
}
|
||||
|
||||
redirect("/gestion-contratos");
|
||||
}
|
||||
}
|
||||
|
||||
const [contracts, kpis, proposals] = await Promise.all([
|
||||
listContractsForUser(user.id),
|
||||
getContractsKpisForUser(user.id),
|
||||
prisma.proposal.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
}),
|
||||
]);
|
||||
const proposalPdfOptions = proposals
|
||||
.map((proposal) => {
|
||||
const latestPdf = proposal.documents.find((document) => isPdfProposalDocument(document)) ?? null;
|
||||
if (!latestPdf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
proposalId: proposal.id,
|
||||
proposalTitle: proposal.title,
|
||||
documentId: latestPdf.id,
|
||||
fileName: latestPdf.fileName,
|
||||
createdAt: latestPdf.createdAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((item): item is { proposalId: string; proposalTitle: string; documentId: string; fileName: string; createdAt: string } => Boolean(item));
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 8: Gestion Estrategica de Contratos"
|
||||
headerMode="module"
|
||||
headerBackHref="/compliance-alerts"
|
||||
headerBackLabel="M7: Alertas"
|
||||
headerNextHref="/proteccion-legal"
|
||||
headerNextLabel="M9: Legal"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◧</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion Estrategica de Contratos</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Registra contratos, entregables, pagos y evidencia documental con trazabilidad completa</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContractsManagementView initialContracts={contracts} initialKpis={kpis} proposalPdfOptions={proposalPdfOptions} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
305
src/app/gestion-licitaciones/[id]/page.tsx
Normal file
305
src/app/gestion-licitaciones/[id]/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { ProposalWorkflowView } from "@/components/app/proposal-workflow-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type SourceDocumentLink = {
|
||||
name: string;
|
||||
url: string;
|
||||
type: string | null;
|
||||
};
|
||||
|
||||
function toSourceDocumentLinks(value: unknown): SourceDocumentLink[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : "Documento";
|
||||
const url = typeof record.url === "string" && record.url.trim() ? record.url.trim() : "";
|
||||
const type = typeof record.type === "string" && record.type.trim() ? record.type.trim() : null;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, url, type };
|
||||
})
|
||||
.filter((item): item is SourceDocumentLink => Boolean(item));
|
||||
}
|
||||
|
||||
function parseDateCandidate(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toMilestoneSeeds(args: {
|
||||
openingDate: Date | null;
|
||||
closingDate: Date | null;
|
||||
eventDates: Prisma.JsonValue | null;
|
||||
}): WorkflowMilestoneSeed[] {
|
||||
const milestones: WorkflowMilestoneSeed[] = [];
|
||||
|
||||
if (args.openingDate) {
|
||||
milestones.push({
|
||||
id: "opening-date",
|
||||
title: "Apertura del proceso",
|
||||
dateIso: args.openingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.closingDate) {
|
||||
milestones.push({
|
||||
id: "closing-date",
|
||||
title: "Presentacion de propuestas",
|
||||
dateIso: args.closingDate.toISOString(),
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
|
||||
if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) {
|
||||
const entries = Object.entries(args.eventDates as Record<string, unknown>);
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const [key, value] = entries[index];
|
||||
const parsed = parseDateCandidate(value);
|
||||
milestones.push({
|
||||
id: `event-date-${index + 1}`,
|
||||
title: key || `Evento ${index + 1}`,
|
||||
dateIso: parsed ? parsed.toISOString() : null,
|
||||
location: "",
|
||||
note: "",
|
||||
source: "licitation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dedupe = new Set<string>();
|
||||
return milestones.filter((item) => {
|
||||
const signature = `${item.title.toLowerCase()}|${item.dateIso ?? ""}`;
|
||||
if (dedupe.has(signature)) {
|
||||
return false;
|
||||
}
|
||||
dedupe.add(signature);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function GestionLicitacionDetailPage({ params, searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 5);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/normative-analysis">
|
||||
<Button variant="secondary">Ir a Modulo 4</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const autoExtractOnLoad = getParam(resolvedSearchParams, "autofill") === "1";
|
||||
|
||||
const proposal = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
issuingEntity: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
sourceLicitationId: true,
|
||||
updatedAt: true,
|
||||
documents: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!proposal) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const linkedSource = proposal.sourceLicitationId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: proposal.sourceLicitationId },
|
||||
include: {
|
||||
municipality: {
|
||||
select: {
|
||||
stateName: true,
|
||||
municipalityName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const historyEntry = proposal.sourceLicitationId
|
||||
? await prisma.normativeAnalysisHistory.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ analyzedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
sourceLicitationId: true,
|
||||
fileName: true,
|
||||
documentType: true,
|
||||
issuingEntity: true,
|
||||
methodUsed: true,
|
||||
numPages: true,
|
||||
warnings: true,
|
||||
extractedChars: true,
|
||||
analyzedAt: true,
|
||||
viabilityScore: true,
|
||||
riskLevel: true,
|
||||
confidence: true,
|
||||
result: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const historyView = historyEntry ? toNormativeHistoryView(historyEntry) : null;
|
||||
const requirementSeeds = historyView ? buildRequirementSeeds(historyView.result) : [];
|
||||
const technicalSeeds = historyView ? buildTechnicalSectionSeeds(historyView.result) : [];
|
||||
const milestoneSeeds = linkedSource
|
||||
? toMilestoneSeeds({
|
||||
openingDate: linkedSource.openingDate,
|
||||
closingDate: linkedSource.closingDate,
|
||||
eventDates: linkedSource.eventDates,
|
||||
})
|
||||
: [];
|
||||
const sourceDocumentLinks = linkedSource ? toSourceDocumentLinks(linkedSource.documents) : [];
|
||||
const candadosHref =
|
||||
`/candados?proposalId=${encodeURIComponent(proposal.id)}` +
|
||||
(proposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(proposal.sourceLicitationId)}` : "");
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
headerMode="module"
|
||||
headerBackHref="/gestion-licitaciones"
|
||||
headerBackLabel="M5: Lista"
|
||||
headerNextHref={candadosHref}
|
||||
headerNextLabel="M6: Candados"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<ProposalWorkflowView
|
||||
proposal={{
|
||||
id: proposal.id,
|
||||
title: proposal.title,
|
||||
issuingEntity: proposal.issuingEntity,
|
||||
summary: proposal.summary,
|
||||
status: proposal.status,
|
||||
sourceLicitationId: proposal.sourceLicitationId,
|
||||
updatedAt: proposal.updatedAt.toISOString(),
|
||||
documents: proposal.documents.map((document) => ({
|
||||
id: document.id,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType,
|
||||
sizeBytes: document.sizeBytes,
|
||||
createdAt: document.createdAt.toISOString(),
|
||||
})),
|
||||
}}
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
description: linkedSource.description ?? "",
|
||||
issuingEntity: linkedSource.supplierAwarded ?? null,
|
||||
procedureType: linkedSource.procedureType,
|
||||
stateName: linkedSource.municipality.stateName,
|
||||
municipalityName: linkedSource.municipality.municipalityName,
|
||||
sourceUrl: linkedSource.rawSourceUrl ?? null,
|
||||
documentLinks: sourceDocumentLinks,
|
||||
milestones: milestoneSeeds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
normativeContext={
|
||||
historyView
|
||||
? {
|
||||
id: historyView.id,
|
||||
analyzedAt: historyView.analyzedAt,
|
||||
executiveSummary: historyView.result.executiveSummary,
|
||||
confidence: historyView.confidence,
|
||||
requirementSeeds,
|
||||
technicalSeeds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoExtractOnLoad={autoExtractOnLoad}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
133
src/app/gestion-licitaciones/page.tsx
Normal file
133
src/app/gestion-licitaciones/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listUserProposals } from "@/lib/proposals/server";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { ProposalsManagementView } from "@/components/app/proposals-management-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function GestionLicitacionesPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 5);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/normative-analysis">
|
||||
<Button variant="secondary">Ir a Modulo 4</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const sourceLicitationId = getParam(resolvedSearchParams, "sourceId") || null;
|
||||
const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1";
|
||||
const shouldAutoFill = getParam(resolvedSearchParams, "autofill") === "1";
|
||||
|
||||
if (sourceLicitationId && shouldAutoCreate) {
|
||||
const existing = await prisma.proposal.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceLicitationId,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let targetId = existing?.id ?? null;
|
||||
|
||||
if (!targetId) {
|
||||
const source = await prisma.licitation.findUnique({
|
||||
where: { id: sourceLicitationId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (source) {
|
||||
const created = await prisma.proposal.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: source.id,
|
||||
title: source.title,
|
||||
issuingEntity: source.supplierAwarded?.trim() || "Entidad no especificada",
|
||||
summary: source.description ?? "",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
redirect(`/gestion-licitaciones/${encodeURIComponent(targetId)}${shouldAutoFill ? "?autofill=1" : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const proposals = await listUserProposals(user.id, "");
|
||||
const latestProposal = proposals[0] ?? null;
|
||||
const candadosHref =
|
||||
latestProposal
|
||||
? `/candados?proposalId=${encodeURIComponent(latestProposal.id)}${
|
||||
latestProposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(latestProposal.sourceLicitationId)}` : ""
|
||||
}`
|
||||
: sourceLicitationId
|
||||
? `/candados?sourceId=${encodeURIComponent(sourceLicitationId)}`
|
||||
: "/candados";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 5: Gestion de Licitaciones"
|
||||
headerMode="module"
|
||||
headerBackHref="/normative-analysis"
|
||||
headerBackLabel="M4: Analisis"
|
||||
headerNextHref={candadosHref}
|
||||
headerNextLabel="M6: Candados"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion de Licitaciones</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Prepara tus propuestas de licitacion paso a paso</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProposalsManagementView initialProposals={proposals} initialSourceLicitationId={sourceLicitationId} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
|
||||
import { type LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LicitationCardActions } from "@/components/app/licitation-card-actions";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
@@ -25,22 +28,10 @@ function formatDate(value: Date | null) {
|
||||
}
|
||||
|
||||
function toDocumentArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [] as Array<{ name: string; url: string; type?: string }>;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((item) => item && typeof item === "object")
|
||||
.map((item) => {
|
||||
const entry = item as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
name: typeof entry.name === "string" ? entry.name : "Documento",
|
||||
url: typeof entry.url === "string" ? entry.url : "",
|
||||
type: typeof entry.type === "string" ? entry.type : undefined,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.url.length > 0);
|
||||
return parseLicitationDocumentLinks(value).map((item) => ({
|
||||
...item,
|
||||
type: item.type ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function toEventDates(value: unknown) {
|
||||
@@ -60,7 +51,7 @@ function toEventDates(value: unknown) {
|
||||
|
||||
export default async function LicitationDetailPage({ params }: LicitationDetailPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 3);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
@@ -99,16 +90,56 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
}
|
||||
|
||||
const documents = toDocumentArray(licitation.documents);
|
||||
const primaryPdfDocument =
|
||||
pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(licitation.documents)) ??
|
||||
(isLikelyPdfUrl(licitation.rawSourceUrl)
|
||||
? {
|
||||
name: "Documento principal",
|
||||
url: licitation.rawSourceUrl as string,
|
||||
type: "PDF",
|
||||
}
|
||||
: null);
|
||||
const timeline = toEventDates(licitation.eventDates);
|
||||
const preference = await prisma.licitationUserPreference.findUnique({
|
||||
where: {
|
||||
userId_licitationId: {
|
||||
userId: user.id,
|
||||
licitationId: licitation.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
const reviewStatus = (preference?.status as LicitationReviewStatusView | undefined) ?? "NEW";
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Detalle de licitacion"
|
||||
description={`${licitation.municipality.municipalityName}, ${licitation.municipality.stateName}`}
|
||||
headerMode="module"
|
||||
headerBackHref="/licitations"
|
||||
headerBackLabel="Volver a Oportunidades"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
contentWidth="wide"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}>
|
||||
<Button>Analizar en Modulo 4</Button>
|
||||
</Link>
|
||||
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(licitation.id)}&autocreate=1&autofill=1`}>
|
||||
<Button variant="secondary">Crear propuesta (M5)</Button>
|
||||
</Link>
|
||||
{primaryPdfDocument ? (
|
||||
<a href={primaryPdfDocument.url} target="_blank" rel="noreferrer">
|
||||
<Button variant="secondary">Ver PDF principal</Button>
|
||||
</a>
|
||||
) : null}
|
||||
<Link href="/licitations">
|
||||
<Button variant="secondary">Volver a resultados</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
@@ -129,6 +160,12 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Cierre: {formatDate(licitation.closingDate)}</span>
|
||||
</div>
|
||||
|
||||
<LicitationCardActions
|
||||
licitationId={licitation.id}
|
||||
initialStatus={reviewStatus}
|
||||
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#d8e1ef] p-3">
|
||||
<p className="text-xs font-semibold uppercase text-[#60718f]">Monto</p>
|
||||
@@ -170,6 +207,16 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
|
||||
<h3 className="text-lg font-semibold text-[#1f2a40]">Documentos</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{primaryPdfDocument ? (
|
||||
<a
|
||||
href={primaryPdfDocument.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-xl border border-[#c6d8f4] bg-[#ebf3ff] p-3 text-sm font-semibold text-[#1f3f84]"
|
||||
>
|
||||
PDF principal: {primaryPdfDocument.name}
|
||||
</a>
|
||||
) : null}
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-sm text-[#64718a]">No hay documentos asociados.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,22 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { LicitationProcedureType } from "@prisma/client";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import type { LicitationSource } from "@prisma/client";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels";
|
||||
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
|
||||
import { getCategoryLabel, getProcedureTypeLabel } from "@/lib/licitations/labels";
|
||||
import { getLicitationReviewStatusLabel, type LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { listMunicipalities, searchLicitations } from "@/lib/licitations/query";
|
||||
import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations";
|
||||
import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations";
|
||||
import { computeModule3Summary, getViabilityLevel } from "@/lib/licitations/module3";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LicitationCardActions } from "@/components/app/licitation-card-actions";
|
||||
import { LicitationsSyncButton } from "@/components/app/licitations-sync-button";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type LicitationsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type ViabilityFilter = "all" | "alta" | "media" | "baja";
|
||||
type SortFilter = "compat_desc" | "compat_asc" | "closing_soon" | "recent";
|
||||
type StatusFilter = "all" | "open" | "closed";
|
||||
type ReviewFilter = "all" | "consulted" | "interested" | "active";
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
function parseViability(value: string): ViabilityFilter {
|
||||
if (value === "alta" || value === "media" || value === "baja") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function parseSort(value: string): SortFilter {
|
||||
if (value === "compat_asc" || value === "closing_soon" || value === "recent") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "compat_desc";
|
||||
}
|
||||
|
||||
function parseStatus(value: string): StatusFilter {
|
||||
if (value === "open" || value === "closed") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function parseReview(value: string): ReviewFilter {
|
||||
if (value === "consulted" || value === "interested" || value === "active") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
@@ -26,11 +68,18 @@ function formatDate(value: Date | null) {
|
||||
|
||||
return value.toLocaleDateString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(value: Date) {
|
||||
return value.toLocaleDateString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: unknown, currency: string | null) {
|
||||
if (amount == null) {
|
||||
return "Monto no disponible";
|
||||
@@ -42,12 +91,67 @@ function formatAmount(amount: unknown, currency: string | null) {
|
||||
return "Monto no disponible";
|
||||
}
|
||||
|
||||
return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 2 })}`;
|
||||
return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
function getSourceShortLabel(source: LicitationSource) {
|
||||
if (source === "MUNICIPAL_OPEN_PORTAL") {
|
||||
return "Portal municipal";
|
||||
}
|
||||
|
||||
if (source === "MUNICIPAL_BACKUP") {
|
||||
return "Portal respaldo";
|
||||
}
|
||||
|
||||
if (source === "LICITAYA") {
|
||||
return "LicitaYa API";
|
||||
}
|
||||
|
||||
return "PNT";
|
||||
}
|
||||
|
||||
function getDaysUntilDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "Sin cierre";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const difference = value.getTime() - now.getTime();
|
||||
const dayCount = Math.max(0, Math.ceil(difference / (1000 * 60 * 60 * 24)));
|
||||
return `${dayCount} dias`;
|
||||
}
|
||||
|
||||
function ViabilityBadge({ score }: { score: number }) {
|
||||
const level = getViabilityLevel(score);
|
||||
|
||||
const label = level === "alta" ? "Alta Viabilidad" : level === "media" ? "Media Viabilidad" : "Baja Viabilidad";
|
||||
const className =
|
||||
level === "alta"
|
||||
? "border-[#b8e3d0] bg-[#ecf9f2] text-[#1f8b63]"
|
||||
: level === "media"
|
||||
? "border-[#f0deb0] bg-[#fff8e9] text-[#9d6d08]"
|
||||
: "border-[#f2cccc] bg-[#fff1f1] text-[#af3a3a]";
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ReviewStatusBadge({ status }: { status: LicitationReviewStatusView }) {
|
||||
const label = getLicitationReviewStatusLabel(status);
|
||||
const className =
|
||||
status === "REVIEWED"
|
||||
? "border-[#d4dbe8] bg-[#f2f5fb] text-[#42577c]"
|
||||
: status === "INTERESTED"
|
||||
? "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]"
|
||||
: status === "DISCARDED"
|
||||
? "border-[#f0ccd2] bg-[#fff1f4] text-[#a74150]"
|
||||
: "border-[#d4e2ff] bg-[#e8f0ff] text-[#2f58c1]";
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export default async function LicitationsPage({ searchParams }: LicitationsPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 3);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
@@ -57,10 +161,8 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">
|
||||
El Modulo 3 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y ver los planes en la seccion Modulos.
|
||||
</p>
|
||||
@@ -74,14 +176,18 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const q = getParam(params, "q") ?? "";
|
||||
const state = getParam(params, "state") ?? "";
|
||||
const municipality = getParam(params, "municipality") ?? "";
|
||||
const procedureType = getParam(params, "procedure_type") ?? "";
|
||||
const minAmount = getParam(params, "min_amount") ?? "";
|
||||
const maxAmount = getParam(params, "max_amount") ?? "";
|
||||
const dateFrom = getParam(params, "date_from") ?? "";
|
||||
const dateTo = getParam(params, "date_to") ?? "";
|
||||
const q = getParam(params, "q");
|
||||
const state = getParam(params, "state");
|
||||
const municipality = getParam(params, "municipality");
|
||||
const procedureType = getParam(params, "procedure_type");
|
||||
const minAmount = getParam(params, "min_amount");
|
||||
const maxAmount = getParam(params, "max_amount");
|
||||
const dateFrom = getParam(params, "date_from");
|
||||
const dateTo = getParam(params, "date_to");
|
||||
const viabilityFilter = parseViability(getParam(params, "viability"));
|
||||
const statusFilter = parseStatus(getParam(params, "status"));
|
||||
const reviewFilter = parseReview(getParam(params, "review"));
|
||||
const sortFilter = parseSort(getParam(params, "sort"));
|
||||
|
||||
const [municipalities, records, recommendations] = await Promise.all([
|
||||
listMunicipalities(),
|
||||
@@ -96,49 +202,324 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
dateTo,
|
||||
take: 100,
|
||||
}),
|
||||
getLicitationRecommendationsForUser(user.id),
|
||||
getAiEnhancedLicitationRecommendationsForUser(user.id),
|
||||
]);
|
||||
|
||||
const preferenceRecords = records.records.length
|
||||
? await prisma.licitationUserPreference.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
licitationId: {
|
||||
in: records.records.map((record) => record.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
licitationId: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const recommendationsById = new Map(recommendations.results.map((item) => [item.id, item]));
|
||||
const preferencesById = new Map(preferenceRecords.map((item) => [item.licitationId, item.status as LicitationReviewStatusView]));
|
||||
const activeProposalRecords = records.records.length
|
||||
? await prisma.proposal.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sourceLicitationId: {
|
||||
in: records.records.map((record) => record.id),
|
||||
},
|
||||
status: {
|
||||
not: "ARCHIVED",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
sourceLicitationId: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
})
|
||||
: [];
|
||||
const activeLicitationIds = new Set(activeProposalRecords.map((item) => item.sourceLicitationId).filter((value): value is string => Boolean(value)));
|
||||
|
||||
const baseRows = records.records
|
||||
.map((record) => {
|
||||
const recommendation = recommendationsById.get(record.id);
|
||||
const deterministicScore = recommendation?.deterministicScore ?? recommendation?.score ?? 0;
|
||||
const aiFitScore = recommendation?.aiScore ?? deterministicScore;
|
||||
const blendedScore = recommendation?.blendedScore ?? deterministicScore;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
entity: record.supplierAwarded ?? "Entidad no especificada",
|
||||
municipalityName: record.municipality.municipalityName,
|
||||
stateName: record.municipality.stateName,
|
||||
amountLabel: formatAmount(record.amount, record.currency),
|
||||
daysToClose: getDaysUntilDate(record.closingDate),
|
||||
procedureLabel: getProcedureTypeLabel(record.procedureType),
|
||||
categoryLabel: getCategoryLabel(record.category),
|
||||
publishDate: record.publishDate,
|
||||
closingDate: record.closingDate,
|
||||
sourceLabel: getSourceShortLabel(record.source),
|
||||
deterministicScore,
|
||||
aiFitScore,
|
||||
compatibilityScore: blendedScore,
|
||||
blendedScore,
|
||||
reasons: recommendation?.reasons ?? [],
|
||||
aiReasons: recommendation?.aiReasons ?? [],
|
||||
aiRisks: recommendation?.aiRisks ?? [],
|
||||
nextStep: recommendation?.nextStep ?? null,
|
||||
suggestionId: recommendation?.suggestionId ?? null,
|
||||
isOpen: record.isOpen,
|
||||
reviewStatus: preferencesById.get(record.id) ?? "NEW",
|
||||
isActive: activeLicitationIds.has(record.id),
|
||||
primaryPdfUrl:
|
||||
pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(record.documents))?.url ??
|
||||
(isLikelyPdfUrl(record.rawSourceUrl) ? record.rawSourceUrl : null),
|
||||
};
|
||||
})
|
||||
.filter((row) => {
|
||||
if (statusFilter === "open") {
|
||||
return row.isOpen;
|
||||
}
|
||||
|
||||
if (statusFilter === "closed") {
|
||||
return !row.isOpen;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.filter((row) => {
|
||||
if (viabilityFilter === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getViabilityLevel(row.compatibilityScore) === viabilityFilter;
|
||||
});
|
||||
|
||||
const rows = baseRows.filter((row) => {
|
||||
if (reviewFilter === "consulted") {
|
||||
return row.reviewStatus === "REVIEWED";
|
||||
}
|
||||
|
||||
if (reviewFilter === "interested") {
|
||||
return row.reviewStatus === "INTERESTED";
|
||||
}
|
||||
|
||||
if (reviewFilter === "active") {
|
||||
return row.isActive;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedRows = [...rows].sort((left, right) => {
|
||||
if (sortFilter === "compat_asc") {
|
||||
return left.blendedScore - right.blendedScore;
|
||||
}
|
||||
|
||||
if (sortFilter === "closing_soon") {
|
||||
const leftTime = left.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightTime = right.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
if (sortFilter === "recent") {
|
||||
const leftTime = left.publishDate?.getTime() ?? 0;
|
||||
const rightTime = right.publishDate?.getTime() ?? 0;
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return right.blendedScore - left.blendedScore;
|
||||
});
|
||||
|
||||
const uniqueStates = Array.from(new Map(municipalities.map((item) => [item.stateCode, item.stateName])).entries()).map(([code, name]) => ({
|
||||
code,
|
||||
name,
|
||||
}));
|
||||
|
||||
const filteredMunicipalities = state ? municipalities.filter((item) => item.stateCode === state) : municipalities;
|
||||
const now = new Date();
|
||||
const summary = computeModule3Summary(
|
||||
sortedRows.length,
|
||||
sortedRows.map((row) => ({
|
||||
id: row.id,
|
||||
score: row.compatibilityScore,
|
||||
publishDate: row.publishDate?.toISOString() ?? null,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const newToReviewCount = sortedRows.filter((row) => row.reviewStatus === "NEW").length;
|
||||
const consultedCount = baseRows.filter((row) => row.reviewStatus === "REVIEWED").length;
|
||||
const interestedCount = baseRows.filter((row) => row.reviewStatus === "INTERESTED").length;
|
||||
const activeCount = baseRows.filter((row) => row.isActive).length;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 3: Deteccion de Oportunidades"
|
||||
description="Encuentra licitaciones compatibles con tu perfil empresarial"
|
||||
action={<LicitationsSyncButton />}
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dee5f1] text-3xl text-[#264574]">✧</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 3: Deteccion de Oportunidades</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Encuentra licitaciones compatibles con tu perfil empresarial</p>
|
||||
</div>
|
||||
</div>
|
||||
<LicitationsSyncButton />
|
||||
</section>
|
||||
|
||||
<Card className="border-[#d2dbe9] bg-[#f7f9fd]">
|
||||
<CardContent className="flex items-center justify-between py-5">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">Como usar este modulo?</p>
|
||||
<p className="text-base text-[#566d92] md:text-lg">Guia rapida para encontrar oportunidades</p>
|
||||
</div>
|
||||
<span className="text-2xl text-[#273f67]" aria-hidden>
|
||||
˅
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<form method="get" className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.opportunities}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Oportunidades</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.highViability}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Alta viabilidad</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.mediumViability}</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Media viabilidad</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.averageCompatibility}%</p>
|
||||
<p className="text-lg text-[#60718f] md:text-xl">Compatibilidad promedio</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-3">
|
||||
<Link href="/licitations?review=consulted" className="block">
|
||||
<Card className={reviewFilter === "consulted" ? "border-[#b8c9eb] bg-[#edf3ff]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#15305d]">{consultedCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Consultadas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/licitations?review=interested" className="block">
|
||||
<Card className={reviewFilter === "interested" ? "border-[#b9e6cd] bg-[#edf9f2]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#155540]">{interestedCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Me interesa</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/licitations?review=active" className="block">
|
||||
<Card className={reviewFilter === "active" ? "border-[#f0deb0] bg-[#fff9ea]" : undefined}>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-3xl font-semibold text-[#704f05]">{activeCount}</p>
|
||||
<p className="text-sm font-semibold text-[#5f7090]">Activas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<p className="text-base text-[#4d6488] md:text-lg">
|
||||
Ultimo analisis: {formatDateShort(now)}, {now.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })} ·
|
||||
<span className="ml-1 font-semibold text-[#1c3768]">{newToReviewCount} nuevas por revisar</span>
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<form method="get" className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_220px]">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-1">
|
||||
Buscar oportunidades
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Palabra clave, proveedor, concepto..."
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
|
||||
placeholder="Buscar oportunidades..."
|
||||
className="h-12 w-full rounded-xl border border-[#cfd8e6] px-4 text-base"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Tipo de procedimiento
|
||||
<select name="procedure_type" defaultValue={procedureType} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<option value="">Todos</option>
|
||||
<option value={LicitationProcedureType.LICITACION_PUBLICA}>Licitacion publica</option>
|
||||
<option value={LicitationProcedureType.INVITACION_RESTRINGIDA}>Invitacion restringida</option>
|
||||
<option value={LicitationProcedureType.ADJUDICACION_DIRECTA}>Adjudicacion directa</option>
|
||||
Viabilidad
|
||||
<select name="viability" defaultValue={viabilityFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todas</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="media">Media</option>
|
||||
<option value="baja">Baja</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Estatus
|
||||
<select name="status" defaultValue={statusFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todos</option>
|
||||
<option value="open">Abiertas</option>
|
||||
<option value="closed">Cerradas</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Seguimiento M3
|
||||
<select name="review" defaultValue={reviewFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="all">Todos</option>
|
||||
<option value="consulted">Consultadas</option>
|
||||
<option value="interested">Me interesa</option>
|
||||
<option value="active">Activas</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Ordenar por
|
||||
<select name="sort" defaultValue={sortFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="compat_desc">Mayor compatibilidad</option>
|
||||
<option value="compat_asc">Menor compatibilidad</option>
|
||||
<option value="closing_soon">Cierre proximo</option>
|
||||
<option value="recent">Mas recientes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-6">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Procedimiento
|
||||
<select name="procedure_type" defaultValue={procedureType} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos</option>
|
||||
<option value="LICITACION_PUBLICA">Licitacion publica</option>
|
||||
<option value="INVITACION_RESTRINGIDA">Invitacion restringida</option>
|
||||
<option value="ADJUDICACION_DIRECTA">Adjudicacion directa</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
|
||||
Estado
|
||||
<select name="state" defaultValue={state} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<select name="state" defaultValue={state} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos los estados</option>
|
||||
{uniqueStates.map((stateOption) => (
|
||||
<option key={stateOption.code} value={stateOption.code}>
|
||||
@@ -148,9 +529,9 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
|
||||
Municipio
|
||||
<select name="municipality" defaultValue={municipality} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
|
||||
<select name="municipality" defaultValue={municipality} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
|
||||
<option value="">Todos</option>
|
||||
{filteredMunicipalities.map((municipalityOption) => (
|
||||
<option key={municipalityOption.id} value={municipalityOption.municipalityCode}>
|
||||
@@ -162,100 +543,181 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto minimo
|
||||
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Monto maximo
|
||||
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Fecha desde
|
||||
<input type="date" name="date_from" defaultValue={dateFrom} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
<input type="hidden" name="date_from" value={dateFrom} />
|
||||
<input type="hidden" name="date_to" value={dateTo} />
|
||||
|
||||
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
|
||||
Fecha hasta
|
||||
<input type="date" name="date_to" defaultValue={dateTo} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-3 flex gap-2">
|
||||
<Button type="submit">Aplicar filtros</Button>
|
||||
<Link href="/licitations">
|
||||
<Button type="button" variant="secondary">
|
||||
<div className="flex items-end gap-2 lg:col-span-2">
|
||||
<Button type="submit" className="h-12 rounded-xl px-5">
|
||||
Aplicar
|
||||
</Button>
|
||||
<Link href="/licitations" className="inline-flex">
|
||||
<Button type="button" variant="secondary" className="h-12 rounded-xl px-5">
|
||||
Limpiar
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Recomendaciones para tu empresa</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recommendations.results.slice(0, 5).length ? (
|
||||
recommendations.results.slice(0, 5).map((item) => (
|
||||
<div 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-[#1f2f4f]">{item.title}</p>
|
||||
<span className="rounded-full bg-[#e8eefc] px-2 py-1 text-xs font-semibold text-[#1a3f8d]">Score {item.score}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#5c6f8e]">{item.municipalityName}, {item.stateName}</p>
|
||||
<p className="mt-1 text-xs text-[#5c6f8e]">{item.reasons.join(" ")}</p>
|
||||
<Link href={`/licitations/${item.id}`} className="mt-2 inline-flex text-sm font-semibold text-[#1f3f84]">
|
||||
Ver detalle
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-[#64718a]">Aun no hay recomendaciones por perfil. Completa tu perfil y ejecuta sincronizacion.</p>
|
||||
)}
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{sortedRows.length === 0 ? (
|
||||
<Card className="xl:col-span-2">
|
||||
<CardContent className="py-14 text-center">
|
||||
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">No hay oportunidades con estos filtros</p>
|
||||
<p className="mt-2 text-base text-[#5f7090] md:text-lg">Ajusta los filtros o ejecuta una nueva sincronizacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-[#1f2a40]">Resultados ({records.total})</h2>
|
||||
<p className="text-sm text-[#60718f]">Mostrando oportunidades abiertas por defecto.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{records.records.length === 0 ? (
|
||||
<p className="text-sm text-[#64718a]">No se encontraron oportunidades con los filtros seleccionados.</p>
|
||||
) : (
|
||||
records.records.map((record) => (
|
||||
<article key={record.id} className="rounded-xl border border-[#d8e1ef] bg-white p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-[#60718f]">{record.municipality.stateName} / {record.municipality.municipalityName}</p>
|
||||
<h3 className="text-lg font-semibold text-[#1f2a40]">{record.title}</h3>
|
||||
<p className="text-sm text-[#60718f]">{record.description ?? "Sin descripcion"}</p>
|
||||
sortedRows.map((row) => (
|
||||
<article
|
||||
key={row.id}
|
||||
className={`rounded-2xl border border-[#d7dfea] p-6 shadow-[0_1px_2px_rgba(16,24,40,0.06)] ${row.reviewStatus === "DISCARDED" ? "bg-[#fafbfe] opacity-85" : "bg-white"}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReviewStatusBadge status={row.reviewStatus} />
|
||||
<ViabilityBadge score={row.blendedScore} />
|
||||
{row.isActive ? (
|
||||
<span className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 text-sm font-semibold text-[#8d6308]">Activa en M5</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-semibold text-[#12306a] md:text-4xl">{row.blendedScore}%</p>
|
||||
<p className="text-base text-[#5f7290] md:text-lg">score combinado</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#eef3ff] px-2 py-1 text-xs font-semibold text-[#1f3f84]">{getProcedureTypeLabel(record.procedureType)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[#5e7190]">
|
||||
<span className={`rounded-full border px-2 py-1 ${record.isOpen ? "border-[#acd8c7] bg-[#e8f7ef] text-[#1c8f67]" : "border-[#d4dced]"}`}>
|
||||
{record.isOpen ? "Abierta" : "Cerrada"}
|
||||
<h3 className="mt-4 text-2xl font-semibold leading-tight text-[#162748] [font-family:var(--font-display)] md:text-3xl">{row.title}</h3>
|
||||
|
||||
<div className="mt-4 h-4 w-full overflow-hidden rounded-full bg-[#d7e6ef]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-[#1f3f84] via-[#2576a8] to-[#2bb67f]"
|
||||
style={{ width: `${Math.max(8, Math.min(100, row.blendedScore))}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-sm text-[#5d7090] md:grid-cols-3">
|
||||
<p>
|
||||
Determinista: <span className="font-semibold text-[#17315a]">{row.deterministicScore}%</span>
|
||||
</p>
|
||||
<p>
|
||||
AI fit: <span className="font-semibold text-[#17315a]">{row.aiFitScore}%</span>
|
||||
</p>
|
||||
<p>
|
||||
Combinado: <span className="font-semibold text-[#17315a]">{row.blendedScore}%</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-base text-[#596f93] md:grid-cols-2 md:text-lg">
|
||||
<p>{row.entity}</p>
|
||||
<p>{row.municipalityName}</p>
|
||||
<p>{row.amountLabel}</p>
|
||||
<p>{row.daysToClose}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<p className="font-semibold text-[#0f8a56]">Por que es compatible:</p>
|
||||
{row.aiReasons.length ? (
|
||||
row.aiReasons.slice(0, 2).map((reason) => (
|
||||
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-1 font-semibold text-[#1c865e]">
|
||||
{reason}
|
||||
</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">{getCategoryLabel(record.category)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">{formatAmount(record.amount, record.currency)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Publicacion: {formatDate(record.publishDate)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Cierre: {formatDate(record.closingDate)}</span>
|
||||
<span className="rounded-full border border-[#d4dced] px-2 py-1">Fuente: {getSourceLabel(record.source)}</span>
|
||||
))
|
||||
) : row.reasons.length ? (
|
||||
row.reasons.slice(0, 2).map((reason) => (
|
||||
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
|
||||
{reason}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
|
||||
Completa mas datos de perfil para mejorar recomendaciones.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href={`/licitations/${record.id}`} className="mt-3 inline-flex text-sm font-semibold text-[#1f3f84]">
|
||||
Ver detalle
|
||||
{row.aiRisks.length ? (
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="font-semibold text-[#8d6308]">Riesgos detectados por IA:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.aiRisks.slice(0, 2).map((risk) => (
|
||||
<span key={risk} className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 font-semibold text-[#8d6308]">
|
||||
{risk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{row.nextStep ? <p className="mt-2 text-sm font-semibold text-[#264574]">Siguiente paso sugerido: {row.nextStep}</p> : null}
|
||||
|
||||
<details className="mt-4 rounded-xl border border-[#dce4ef] bg-[#f9fbff] p-3">
|
||||
<summary className="cursor-pointer text-lg font-semibold text-[#1f3f84] md:text-xl">Ver resumen rapido</summary>
|
||||
<div className="mt-3 space-y-2 text-base text-[#5d7090] md:text-lg">
|
||||
<p>Procedimiento: {row.procedureLabel}</p>
|
||||
<p>Categoria: {row.categoryLabel}</p>
|
||||
<p>Fuente: {row.sourceLabel}</p>
|
||||
<p>Publicacion: {formatDate(row.publishDate)}</p>
|
||||
<p>Cierre: {formatDate(row.closingDate)}</p>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<LicitationCardActions
|
||||
licitationId={row.id}
|
||||
initialStatus={row.reviewStatus}
|
||||
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={`/licitations/${encodeURIComponent(row.id)}`}>
|
||||
<Button size="sm">Ir al detalle</Button>
|
||||
</Link>
|
||||
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Analizar en M4
|
||||
</Button>
|
||||
</Link>
|
||||
{row.primaryPdfUrl ? (
|
||||
<a href={row.primaryPdfUrl} target="_blank" rel="noreferrer">
|
||||
<Button size="sm" variant="secondary">
|
||||
Ver PDF principal
|
||||
</Button>
|
||||
</a>
|
||||
) : null}
|
||||
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(row.id)}&autocreate=1&autofill=1`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Crear propuesta (M5)
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#c9dfd2] bg-[#f0faf4] px-6 py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-[#17315a] [font-family:var(--font-display)] md:text-3xl">Te interesa una oportunidad?</p>
|
||||
<p className="text-base text-[#5c7395] md:text-lg">Descarga las bases de licitacion y continua en Analisis Normativo (M4).</p>
|
||||
</div>
|
||||
<Link href="/normative-analysis" className="inline-flex">
|
||||
<Button size="lg" className="rounded-2xl px-7">
|
||||
Ir a Analisis Normativo (M4)
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
113
src/app/normative-analysis/page.tsx
Normal file
113
src/app/normative-analysis/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NormativeAnalysisView } from "@/components/app/normative-analysis-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type NormativeAnalysisPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function NormativeAnalysisPage({ searchParams }: NormativeAnalysisPageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 4);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 4: Analisis Normativo"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">
|
||||
El Modulo 4 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y revisar los planes en la seccion Modulos.
|
||||
</p>
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const sourceId = getParam(params, "sourceId").trim();
|
||||
const linkedSource = sourceId
|
||||
? await prisma.licitation.findUnique({
|
||||
where: { id: sourceId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
supplierAwarded: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 4: Analisis Normativo"
|
||||
headerMode="module"
|
||||
headerBackHref="/dashboard"
|
||||
headerBackLabel="Volver al Dashboard"
|
||||
headerShowManual
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◨</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 4: Analisis Normativo</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Analiza bases de licitacion, reglamentos y leyes con IA</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NormativeAnalysisView
|
||||
linkedSource={
|
||||
linkedSource
|
||||
? {
|
||||
id: linkedSource.id,
|
||||
title: linkedSource.title,
|
||||
issuingEntity: linkedSource.supplierAwarded,
|
||||
}
|
||||
: null
|
||||
}
|
||||
autoAnalyzeOnLoad={Boolean(linkedSource)}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-[#d8e0ec] bg-[#f4f7fb] px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-3xl font-semibold text-[#17315a] [font-family:var(--font-display)]">Navegacion</p>
|
||||
<p className="text-lg text-[#5c7395]">Regresa al Modulo 3 para ver mas oportunidades o vuelve al dashboard.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/licitations">
|
||||
<Button size="lg" className="rounded-2xl px-6">
|
||||
Volver a Oportunidades
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard">
|
||||
<Button size="lg" variant="secondary" className="rounded-2xl px-6">
|
||||
Ir al Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
103
src/app/proteccion-legal/page.tsx
Normal file
103
src/app/proteccion-legal/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { listLegalTemplates } from "@/lib/legal/ai-documents";
|
||||
import { getLegalKpisForUser, listLegalCasesForUser, listLegalDirectory } from "@/lib/legal/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LegalProtectionView } from "@/components/app/legal-protection-view";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function ProteccionLegalPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 9);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 9: Proteccion Legal"
|
||||
description="Este modulo esta protegido por suscripcion de pago."
|
||||
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-6">
|
||||
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
|
||||
<p className="text-sm text-[#64718a]">Modulo 9 forma parte de la ruta premium de modulos pagados.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard#modulos">
|
||||
<Button>Ver modulos y planes</Button>
|
||||
</Link>
|
||||
<Link href="/gestion-contratos">
|
||||
<Button variant="secondary">Ir a Modulo 8</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const contractId = getParam(resolvedSearchParams, "contractId") || null;
|
||||
|
||||
const [cases, kpis, directory, contracts] = await Promise.all([
|
||||
listLegalCasesForUser(user.id),
|
||||
getLegalKpisForUser(user.id),
|
||||
listLegalDirectory({}),
|
||||
prisma.contractRecord.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
contractNumber: true,
|
||||
counterpartyEntity: true,
|
||||
status: true,
|
||||
},
|
||||
take: 200,
|
||||
}),
|
||||
]);
|
||||
const templates = listLegalTemplates();
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Modulo 9: Proteccion Legal"
|
||||
headerMode="module"
|
||||
headerBackHref="/gestion-contratos"
|
||||
headerBackLabel="M8: Contratos"
|
||||
headerNextHref="/expediente-preventivo"
|
||||
headerNextLabel="M10: Auditorias"
|
||||
headerPlanBadgeLabel="Plan Premium"
|
||||
showPageHeading={false}
|
||||
contentWidth="wide"
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="flex items-start gap-4">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]">◪</span>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Proteccion Legal</h1>
|
||||
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Diagnostico guiado, gestion de casos, escalada y generacion de escritos con trazabilidad</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LegalProtectionView
|
||||
initialCases={cases}
|
||||
initialKpis={kpis}
|
||||
initialDirectory={directory}
|
||||
initialTemplates={templates}
|
||||
availableContracts={contracts}
|
||||
prefilledContractId={contractId}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasModuleAccess } from "@/lib/auth/module-access";
|
||||
import { StrategicDiagnosticWizard } from "@/components/app/strategic-diagnostic-wizard";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,7 +9,7 @@ import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/serve
|
||||
|
||||
export default async function StrategicDiagnosticPage() {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasModuleAccess(user, 2);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { hasAnyPaidModuleAccess } from "@/lib/auth/module-access";
|
||||
import { requireOnboardedUser } from "@/lib/auth/user";
|
||||
import { PageShell } from "@/components/app/page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,7 +27,7 @@ function parseDimension(searchParams: Record<string, string | string[] | undefin
|
||||
|
||||
export default async function TalleresDesarrolloPage({ searchParams }: PageProps) {
|
||||
const user = await requireOnboardedUser();
|
||||
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
|
||||
const hasPaidModulesAccess = await hasAnyPaidModuleAccess(user.id, user.email, user.role);
|
||||
|
||||
if (!hasPaidModulesAccess) {
|
||||
return (
|
||||
|
||||
114
src/components/app/__tests__/compliance-alerts-view.test.tsx
Normal file
114
src/components/app/__tests__/compliance-alerts-view.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view";
|
||||
|
||||
function buildDataset(overrides?: Partial<M7Dataset>): M7Dataset {
|
||||
return {
|
||||
generatedAt: "2026-04-02T10:00:00.000Z",
|
||||
kpis: {
|
||||
activeLicitations: 2,
|
||||
completionRate: 64,
|
||||
criticalPending: 1,
|
||||
upcoming7Days: 3,
|
||||
},
|
||||
m3States: {
|
||||
consulted: 1,
|
||||
interested: 2,
|
||||
active: 1,
|
||||
new: 5,
|
||||
},
|
||||
tabs: {
|
||||
plazos: [
|
||||
{
|
||||
id: "plazo-1",
|
||||
proposalId: "p-1",
|
||||
licitationId: "l-1",
|
||||
title: "Cierre de convocatoria",
|
||||
description: "Fecha limite",
|
||||
dueAt: "2026-04-03T12:00:00.000Z",
|
||||
source: "milestone",
|
||||
status: "upcoming",
|
||||
},
|
||||
],
|
||||
alertas: [
|
||||
{
|
||||
id: "alerta-1",
|
||||
proposalId: "p-1",
|
||||
licitationId: "l-1",
|
||||
title: "Vence pronto",
|
||||
description: "Requisito critico",
|
||||
severity: "high",
|
||||
kind: "deadline_soon",
|
||||
dueAt: "2026-04-03T12:00:00.000Z",
|
||||
createdAt: "2026-04-02T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
checklist: [
|
||||
{
|
||||
proposalId: "p-1",
|
||||
proposalTitle: "Propuesta 1",
|
||||
mandatoryResolved: 4,
|
||||
mandatoryTotal: 6,
|
||||
completionRate: 67,
|
||||
signaturePolicyStatus: "condicionado",
|
||||
},
|
||||
],
|
||||
panelKpi: [
|
||||
{
|
||||
id: "panel-1",
|
||||
label: "Licitaciones activas",
|
||||
value: "2",
|
||||
tone: "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
...(overrides ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComplianceAlertsView UI contract", () => {
|
||||
it("renders the four KPI cards and tab labels", () => {
|
||||
const html = renderToStaticMarkup(<ComplianceAlertsView dataset={buildDataset()} />);
|
||||
|
||||
expect(html).toContain("Licitaciones activas");
|
||||
expect(html).toContain("Tasa de completitud");
|
||||
expect(html).toContain("Pendientes criticos");
|
||||
expect(html).toContain("Proximos 7 dias");
|
||||
expect(html).toContain("Plazos");
|
||||
expect(html).toContain("Alertas");
|
||||
expect(html).toContain("Checklist");
|
||||
expect(html).toContain("Panel KPI");
|
||||
});
|
||||
|
||||
it("renders empty state when there is no active compliance data", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComplianceAlertsView
|
||||
dataset={buildDataset({
|
||||
kpis: {
|
||||
activeLicitations: 0,
|
||||
completionRate: 0,
|
||||
criticalPending: 0,
|
||||
upcoming7Days: 0,
|
||||
},
|
||||
tabs: {
|
||||
plazos: [],
|
||||
alertas: [],
|
||||
checklist: [],
|
||||
panelKpi: [],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Sin alertas de cumplimiento");
|
||||
});
|
||||
|
||||
it("keeps responsive layout classes for desktop and mobile grids", () => {
|
||||
const html = renderToStaticMarkup(<ComplianceAlertsView dataset={buildDataset()} />);
|
||||
|
||||
expect(html).toContain("md:grid-cols-2");
|
||||
expect(html).toContain("xl:grid-cols-4");
|
||||
expect(html).toContain("md:grid-cols-4");
|
||||
});
|
||||
});
|
||||
388
src/components/app/compliance-alerts-view.tsx
Normal file
388
src/components/app/compliance-alerts-view.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { M7Dataset } from "@/lib/compliance/types";
|
||||
|
||||
type ComplianceAlertsViewProps = {
|
||||
dataset: M7Dataset;
|
||||
};
|
||||
|
||||
type M7AiPlaybook = {
|
||||
predictedIncidents: {
|
||||
title: string;
|
||||
likelihood: "alta" | "media" | "baja";
|
||||
impact: "alto" | "medio" | "bajo";
|
||||
timeHorizon: string;
|
||||
}[];
|
||||
priorityOrder: string[];
|
||||
preventiveActions: {
|
||||
action: string;
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
escalationAdvice: string[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
function toneClass(tone: "neutral" | "success" | "warning" | "danger") {
|
||||
if (tone === "success") {
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
if (tone === "warning") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
if (tone === "danger") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
return "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]";
|
||||
}
|
||||
|
||||
function severityClass(severity: "high" | "medium" | "low") {
|
||||
if (severity === "high") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
if (severity === "medium") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
export function ComplianceAlertsView({ dataset }: ComplianceAlertsViewProps) {
|
||||
const [isLoadingAiPlaybook, setIsLoadingAiPlaybook] = useState(false);
|
||||
const [aiPlaybook, setAiPlaybook] = useState<M7AiPlaybook | null>(null);
|
||||
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
|
||||
const [aiFeedback, setAiFeedback] = useState<string | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
|
||||
async function setSuggestionDecision(decision: "accept" | "dismiss") {
|
||||
if (!aiSuggestionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`/api/ai/suggestions/${encodeURIComponent(aiSuggestionId)}/decision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
}
|
||||
|
||||
async function generateAiPlaybook() {
|
||||
setIsLoadingAiPlaybook(true);
|
||||
setAiError(null);
|
||||
setAiFeedback(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/compliance/m7/ai/playbook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ dataset }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
predictedIncidents?: M7AiPlaybook["predictedIncidents"];
|
||||
priorityOrder?: M7AiPlaybook["priorityOrder"];
|
||||
preventiveActions?: M7AiPlaybook["preventiveActions"];
|
||||
escalationAdvice?: M7AiPlaybook["escalationAdvice"];
|
||||
confidence?: M7AiPlaybook["confidence"];
|
||||
suggestionId?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setAiError(payload.error ?? "No fue posible generar plan IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiPlaybook({
|
||||
predictedIncidents: payload.predictedIncidents ?? [],
|
||||
priorityOrder: payload.priorityOrder ?? [],
|
||||
preventiveActions: payload.preventiveActions ?? [],
|
||||
escalationAdvice: payload.escalationAdvice ?? [],
|
||||
confidence: payload.confidence ?? "low",
|
||||
});
|
||||
setAiSuggestionId(payload.suggestionId ?? null);
|
||||
setAiFeedback("Plan IA generado.");
|
||||
} catch {
|
||||
setAiError("No fue posible generar plan IA.");
|
||||
} finally {
|
||||
setIsLoadingAiPlaybook(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
dataset.kpis.activeLicitations === 0 &&
|
||||
dataset.tabs.plazos.length === 0 &&
|
||||
dataset.tabs.alertas.length === 0 &&
|
||||
dataset.tabs.checklist.length === 0;
|
||||
|
||||
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]">Licitaciones activas</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.activeLicitations}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Tasa de completitud</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.completionRate}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Pendientes criticos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.criticalPending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Proximos 7 dias</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.upcoming7Days}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Consultadas</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.consulted}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Me interesa</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.interested}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Activas M5</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.active}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Nuevas M3</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.new}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{isEmpty ? (
|
||||
<Card className="border-dashed border-[#cfd8e6] bg-[#fbfdff]">
|
||||
<CardContent className="py-16 text-center">
|
||||
<p className="text-3xl font-semibold text-[#162748] [font-family:var(--font-display)]">Sin alertas de cumplimiento</p>
|
||||
<p className="mt-2 text-base text-[#5f7090]">Cuando tengas procesos activos en M5 y fechas detectadas, este tablero mostrara riesgos y vencimientos.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{aiError ? <p className="rounded-lg border border-[#f1c7ce] bg-[#fff1f4] px-3 py-2 text-sm text-[#b03f4f]">{aiError}</p> : null}
|
||||
{aiFeedback ? <p className="rounded-lg border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-2 text-sm text-[#1f8b63]">{aiFeedback}</p> : null}
|
||||
|
||||
<Tabs
|
||||
defaultTab="plazos"
|
||||
items={[
|
||||
{
|
||||
id: "plazos",
|
||||
label: "Plazos",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.plazos.length === 0 ? <p className="text-sm text-[#60718f]">Sin plazos detectables para los proximos dias.</p> : null}
|
||||
{dataset.tabs.plazos.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.title}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${item.status === "overdue" ? "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]" : "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]"}`}>
|
||||
{item.status === "overdue" ? "Vencido" : "Programado"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "alertas",
|
||||
label: "Alertas",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.alertas.length === 0 ? <p className="text-sm text-[#60718f]">Sin alertas activas.</p> : null}
|
||||
{dataset.tabs.alertas.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.title}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityClass(item.severity)}`}>{item.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
{item.dueAt ? <p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "checklist",
|
||||
label: "Checklist",
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
{dataset.tabs.checklist.length === 0 ? <p className="text-sm text-[#60718f]">No hay procesos activos para checklist.</p> : null}
|
||||
{dataset.tabs.checklist.map((item) => (
|
||||
<article key={item.proposalId} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.proposalTitle}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">
|
||||
Obligatorios resueltos: {item.mandatoryResolved}/{item.mandatoryTotal} ({item.completionRate}%)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">Firma: {item.signaturePolicyStatus.replaceAll("_", " ")}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "panel-kpi",
|
||||
label: "Panel KPI",
|
||||
content: (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{dataset.tabs.panelKpi.map((item) => (
|
||||
<article key={item.id} className={`rounded-xl border p-3 ${toneClass(item.tone)}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em]">{item.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{item.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "plan-ia",
|
||||
label: "Plan IA",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isLoadingAiPlaybook} onClick={() => void generateAiPlaybook()}>
|
||||
{isLoadingAiPlaybook ? "Generando plan IA..." : "Generar plan IA"}
|
||||
</Button>
|
||||
{aiPlaybook ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("dismiss");
|
||||
setAiPlaybook(null);
|
||||
setAiFeedback("Plan IA descartado.");
|
||||
}}
|
||||
>
|
||||
Descartar plan IA
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!aiPlaybook ? <p className="text-sm text-[#60718f]">Sin plan IA generado todavia.</p> : null}
|
||||
|
||||
{aiPlaybook ? (
|
||||
<>
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Incidentes predichos</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiPlaybook.predictedIncidents.map((incident) => (
|
||||
<li key={`${incident.title}-${incident.timeHorizon}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{incident.title}</p>
|
||||
<p>
|
||||
Probabilidad {incident.likelihood} · Impacto {incident.impact} · Horizonte {incident.timeHorizon}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Orden de prioridad</p>
|
||||
<ol className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiPlaybook.priorityOrder.map((item, index) => (
|
||||
<li key={`${item}-${index}`}>
|
||||
{index + 1}. {item}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Acciones preventivas</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiPlaybook.preventiveActions.map((action) => (
|
||||
<li key={`${action.action}-${action.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{action.action}</p>
|
||||
<p>
|
||||
Responsable sugerido: {action.ownerSuggestion} · Fecha objetivo: {action.targetDate}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Recomendaciones de escalamiento</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiPlaybook.escalationAdvice.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("accept");
|
||||
setAiFeedback("Plan IA marcado como aceptado.");
|
||||
}}
|
||||
>
|
||||
Aceptar plan IA
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1046
src/components/app/contracts-management-view.tsx
Normal file
1046
src/components/app/contracts-management-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,13 +43,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)]">Tu Indice de Madurez</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-3xl">Tu Indice de Madurez</h3>
|
||||
<p className="text-sm text-[#60718f]">Visualiza tu progreso hacia el siguiente nivel de preparacion.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center">
|
||||
<p className="text-7xl font-semibold text-[#20a777]">{Math.round(snapshot.overallMaturity)}%</p>
|
||||
<p className="text-2xl font-semibold text-[#213252]">Puntaje Global</p>
|
||||
<p className="text-5xl font-semibold text-[#20a777] md:text-6xl">{Math.round(snapshot.overallMaturity)}%</p>
|
||||
<p className="text-xl font-semibold text-[#213252]">Puntaje Global</p>
|
||||
<span className="mt-2 inline-flex rounded-full bg-[#24a977] px-4 py-1 text-sm font-semibold text-white">
|
||||
Nivel {snapshot.maturityLevel.label}
|
||||
</span>
|
||||
@@ -69,7 +69,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<div className="rounded-2xl border border-[#d6e0ee] bg-[#f5faf7] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-3xl font-semibold text-[#18305f]">
|
||||
<p className="text-2xl font-semibold text-[#18305f] md:text-3xl">
|
||||
Proximo nivel: <span className="text-[#d39b1c]">{snapshot.nextLevel?.label ?? "Avanzado"}</span>
|
||||
</p>
|
||||
{snapshot.nextLevel ? (
|
||||
@@ -101,7 +101,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Tu RADAR Empresarial</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Tu RADAR Empresarial</h3>
|
||||
<p className="text-sm text-[#60718f]">Visualiza tus fortalezas y areas de mejora en 5 dimensiones.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -116,7 +116,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Puntaje por Dimension</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Puntaje por Dimension</h3>
|
||||
<p className="text-sm text-[#60718f]">Cada respuesta afirmativa suma 20 puntos. Maximo 100 por dimension.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -125,7 +125,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-[#1f2f4c]">{statusIcon(dimension.gapLevel)}</span>
|
||||
<p className="text-3xl font-semibold text-[#1a2c4b]">{dimension.moduleName}</p>
|
||||
<p className="text-xl font-semibold text-[#1a2c4b] md:text-2xl">{dimension.moduleName}</p>
|
||||
<Link href={`/talleres-desarrollo?dimension=${dimension.moduleKey}`}>
|
||||
<Button size="sm" className="h-8 rounded-full bg-[#25a878] px-4 text-sm hover:bg-[#1f9368]">
|
||||
Ver talleres
|
||||
@@ -134,7 +134,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full border px-3 py-1 text-sm font-semibold ${gapTone(dimension.gapLevel)}`}>{dimension.gapLabel}</span>
|
||||
<span className="text-2xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
|
||||
<span className="text-xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 overflow-hidden rounded-full bg-[#d9e2ee]">
|
||||
@@ -152,7 +152,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Analisis de Brechas por Dimension</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Analisis de Brechas por Dimension</h3>
|
||||
<p className="text-sm text-[#60718f]">Identificacion de riesgos asociados a cada area de tu empresa.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
@@ -193,13 +193,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Recomendaciones Estrategicas Generales</h3>
|
||||
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Recomendaciones Estrategicas Generales</h3>
|
||||
<p className="text-sm text-[#60718f]">3 acciones prioritarias para mejorar tu posicion competitiva.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{snapshot.recommendations.map((recommendation, index) => (
|
||||
<article key={recommendation.id} className="rounded-2xl border border-[#d4dceb] bg-[#fdfefe] p-4">
|
||||
<p className="text-4xl font-semibold text-[#102345]">
|
||||
<p className="text-2xl font-semibold text-[#102345] md:text-3xl">
|
||||
{index + 1}. {recommendation.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#5f7395]">{recommendation.description}</p>
|
||||
@@ -211,19 +211,19 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es RADAR?</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es RADAR?</p>
|
||||
<p className="text-sm text-[#60718f]">El diagnostico RADAR evalua 5 dimensiones clave para identificar tu nivel de preparacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es CRECE?</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es CRECE?</p>
|
||||
<p className="text-sm text-[#60718f]">Es tu ruta de fortalecimiento empresarial con capacitacion, rediseno y preparacion.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Indice de Madurez</p>
|
||||
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Indice de Madurez</p>
|
||||
<p className="text-sm text-[#60718f]">Mide tu evolucion de Inicial a En Desarrollo, Preparado y Avanzado.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
891
src/components/app/legal-protection-view.tsx
Normal file
891
src/components/app/legal-protection-view.tsx
Normal file
@@ -0,0 +1,891 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
DiagnosisRecommendation,
|
||||
LegalCaseView,
|
||||
LegalContractOptionView,
|
||||
LegalDirectoryEntityView,
|
||||
LegalKpiSnapshot,
|
||||
LegalTemplateView,
|
||||
} from "@/lib/legal/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 LegalProtectionViewProps = {
|
||||
initialCases: LegalCaseView[];
|
||||
initialKpis: LegalKpiSnapshot;
|
||||
initialDirectory: LegalDirectoryEntityView[];
|
||||
initialTemplates: LegalTemplateView[];
|
||||
availableContracts: LegalContractOptionView[];
|
||||
prefilledContractId?: string | null;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
kpis?: LegalKpiSnapshot;
|
||||
cases?: LegalCaseView[];
|
||||
case?: LegalCaseView | null;
|
||||
diagnosis?: {
|
||||
id: string;
|
||||
legalCaseId: string | null;
|
||||
recommendation: DiagnosisRecommendation;
|
||||
};
|
||||
escalation?: {
|
||||
caseId: string;
|
||||
steps: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
estimatedWindow: string;
|
||||
checklist: string[];
|
||||
requiresLawyer: boolean;
|
||||
dependsOn: string | null;
|
||||
completedAt: string | null;
|
||||
notes: string;
|
||||
isBlocked: boolean;
|
||||
}>;
|
||||
};
|
||||
templates?: LegalTemplateView[];
|
||||
entities?: LegalDirectoryEntityView[];
|
||||
document?: {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
generation?: {
|
||||
warnings?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
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.toLocaleString("es-MX", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function severityTone(severity: LegalCaseView["severity"]) {
|
||||
if (severity === "HIGH") {
|
||||
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
|
||||
}
|
||||
|
||||
if (severity === "MEDIUM") {
|
||||
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
|
||||
}
|
||||
|
||||
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
|
||||
}
|
||||
|
||||
function formatContractOptionLabel(contract: LegalContractOptionView) {
|
||||
const numberLabel = contract.contractNumber ? `#${contract.contractNumber}` : "Sin numero";
|
||||
return `${contract.title} - ${contract.counterpartyEntity} (${numberLabel})`;
|
||||
}
|
||||
|
||||
export function LegalProtectionView({
|
||||
initialCases,
|
||||
initialKpis,
|
||||
initialDirectory,
|
||||
initialTemplates,
|
||||
availableContracts,
|
||||
prefilledContractId,
|
||||
}: LegalProtectionViewProps) {
|
||||
const [cases, setCases] = useState(initialCases);
|
||||
const [kpis, setKpis] = useState(initialKpis);
|
||||
const [directory, setDirectory] = useState(initialDirectory);
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [diagnosisStep, setDiagnosisStep] = useState(1);
|
||||
const [diagnosisId, setDiagnosisId] = useState<string | null>(null);
|
||||
const [diagnosisRecommendation, setDiagnosisRecommendation] = useState<DiagnosisRecommendation | null>(null);
|
||||
const [diagnosisAnswers, setDiagnosisAnswers] = useState({
|
||||
breachType: "contract_breach",
|
||||
processStage: "none",
|
||||
evidenceLevel: "basic",
|
||||
urgency: "medium",
|
||||
objective: "payment",
|
||||
amountAtRisk: "",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
});
|
||||
const [diagnosisContractId, setDiagnosisContractId] = useState(prefilledContractId ?? "");
|
||||
|
||||
const [newCase, setNewCase] = useState({
|
||||
contractId: prefilledContractId ?? "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
amountAtRisk: "",
|
||||
status: "OPEN",
|
||||
});
|
||||
|
||||
const [selectedEscalationCaseId, setSelectedEscalationCaseId] = useState("");
|
||||
const [escalation, setEscalation] = useState<ApiPayload["escalation"] | null>(null);
|
||||
|
||||
const [documentDraft, setDocumentDraft] = useState({
|
||||
legalCaseId: "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
templateKey: "",
|
||||
counterparty: "",
|
||||
companyName: "",
|
||||
amountAtRisk: "",
|
||||
description: "",
|
||||
objective: "",
|
||||
title: "",
|
||||
});
|
||||
const [generatedDocuments, setGeneratedDocuments] = useState<Array<{ id: string; title: string; content: string; createdAt: string }>>([]);
|
||||
|
||||
const [directoryFilters, setDirectoryFilters] = useState({
|
||||
jurisdictionLevel: "",
|
||||
q: "",
|
||||
});
|
||||
|
||||
const currentEscalationCase = useMemo(
|
||||
() => cases.find((item) => item.id === selectedEscalationCaseId) ?? null,
|
||||
[cases, selectedEscalationCaseId],
|
||||
);
|
||||
const contractOptions = useMemo(() => {
|
||||
const options = availableContracts.map((item) => ({
|
||||
id: item.id,
|
||||
label: formatContractOptionLabel(item),
|
||||
isPrefilled: item.id === prefilledContractId,
|
||||
}));
|
||||
|
||||
if (prefilledContractId && !options.some((item) => item.id === prefilledContractId)) {
|
||||
options.unshift({
|
||||
id: prefilledContractId,
|
||||
label: `${prefilledContractId} (prefill)`,
|
||||
isPrefilled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [availableContracts, prefilledContractId]);
|
||||
|
||||
async function reloadDataset() {
|
||||
const [kpisResponse, casesResponse, templatesResponse] = await Promise.all([
|
||||
fetch("/api/legal/kpis"),
|
||||
fetch("/api/legal/cases"),
|
||||
fetch("/api/legal/templates"),
|
||||
]);
|
||||
|
||||
const kpisPayload = (await kpisResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
const casesPayload = (await casesResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
const templatesPayload = (await templatesResponse.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (kpisResponse.ok && kpisPayload.ok && kpisPayload.kpis) {
|
||||
setKpis(kpisPayload.kpis);
|
||||
}
|
||||
|
||||
if (casesResponse.ok && casesPayload.ok && casesPayload.cases) {
|
||||
setCases(casesPayload.cases);
|
||||
}
|
||||
|
||||
if (templatesResponse.ok && templatesPayload.ok && templatesPayload.templates) {
|
||||
setTemplates(templatesPayload.templates);
|
||||
}
|
||||
}
|
||||
|
||||
async function startDiagnosis() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/diagnosis/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contractId: diagnosisContractId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.diagnosis) {
|
||||
setErrorMessage(payload.error ?? "No fue posible iniciar diagnostico.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDiagnosisId(payload.diagnosis.id);
|
||||
setDiagnosisStep(1);
|
||||
setDiagnosisRecommendation(payload.diagnosis.recommendation);
|
||||
setMessage("Diagnostico iniciado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible iniciar diagnostico.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDiagnosisStep(finalize = false) {
|
||||
if (!diagnosisId) {
|
||||
await startDiagnosis();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/diagnosis/answer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
diagnosisId,
|
||||
stepIndex: diagnosisStep,
|
||||
finalize,
|
||||
answers: {
|
||||
breachType: diagnosisAnswers.breachType,
|
||||
processStage: diagnosisAnswers.processStage,
|
||||
evidenceLevel: diagnosisAnswers.evidenceLevel,
|
||||
urgency: diagnosisAnswers.urgency,
|
||||
objective: diagnosisAnswers.objective,
|
||||
amountAtRisk: diagnosisAnswers.amountAtRisk ? Number(diagnosisAnswers.amountAtRisk) : null,
|
||||
counterparty: diagnosisAnswers.counterparty,
|
||||
description: diagnosisAnswers.description,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.diagnosis) {
|
||||
setErrorMessage(payload.error ?? "No fue posible registrar respuesta del diagnostico.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDiagnosisRecommendation(payload.diagnosis.recommendation);
|
||||
if (finalize) {
|
||||
setMessage("Diagnostico finalizado y caso legal generado.");
|
||||
setDiagnosisStep(4);
|
||||
await reloadDataset();
|
||||
} else {
|
||||
setDiagnosisStep((prev) => Math.min(4, prev + 1));
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage("No fue posible registrar respuesta del diagnostico.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCase() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/cases", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contractId: newCase.contractId || null,
|
||||
caseType: newCase.caseType,
|
||||
severity: newCase.severity,
|
||||
counterparty: newCase.counterparty,
|
||||
description: newCase.description,
|
||||
amountAtRisk: newCase.amountAtRisk ? Number(newCase.amountAtRisk) : null,
|
||||
status: newCase.status,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible crear el caso legal.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Caso legal creado.");
|
||||
setNewCase({
|
||||
contractId: prefilledContractId ?? "",
|
||||
caseType: "CONTRACT_BREACH",
|
||||
severity: "MEDIUM",
|
||||
counterparty: "",
|
||||
description: "",
|
||||
amountAtRisk: "",
|
||||
status: "OPEN",
|
||||
});
|
||||
await reloadDataset();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible crear el caso legal.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCaseStatus(caseId: string, status: LegalCaseView["status"]) {
|
||||
setErrorMessage(null);
|
||||
|
||||
const resolvedAt = status === "RESOLVED" || status === "CLOSED" ? new Date().toISOString() : null;
|
||||
const response = await fetch(`/api/legal/cases/${encodeURIComponent(caseId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status, resolvedAt }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar el caso.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Caso actualizado.");
|
||||
await reloadDataset();
|
||||
}
|
||||
|
||||
async function loadEscalation(caseId: string) {
|
||||
if (!caseId) {
|
||||
setEscalation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.escalation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar la ruta de escalada.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEscalation(payload.escalation);
|
||||
}
|
||||
|
||||
async function toggleEscalationStep(caseId: string, stepKey: string, completed: boolean, notes: string) {
|
||||
const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routeStepKey: stepKey,
|
||||
completed,
|
||||
notes,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.escalation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar la escalada.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEscalation(payload.escalation);
|
||||
setMessage("Escalada actualizada.");
|
||||
}
|
||||
|
||||
async function generateDocument() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/legal/documents/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legalCaseId: documentDraft.legalCaseId || null,
|
||||
caseType: documentDraft.caseType,
|
||||
severity: documentDraft.severity,
|
||||
templateKey: documentDraft.templateKey || null,
|
||||
counterparty: documentDraft.counterparty,
|
||||
companyName: documentDraft.companyName,
|
||||
amountAtRisk: documentDraft.amountAtRisk ? Number(documentDraft.amountAtRisk) : null,
|
||||
description: documentDraft.description,
|
||||
objective: documentDraft.objective,
|
||||
title: documentDraft.title || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.document) {
|
||||
setErrorMessage(payload.error ?? "No fue posible generar el escrito.");
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedDocuments((prev) => [payload.document as { id: string; title: string; content: string; createdAt: string }, ...prev]);
|
||||
const warningText = payload.generation?.warnings?.join(" | ");
|
||||
setMessage(warningText ? `Escrito generado. ${warningText}` : "Escrito generado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible generar el escrito.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDirectory() {
|
||||
const params = new URLSearchParams();
|
||||
if (directoryFilters.jurisdictionLevel) {
|
||||
params.set("jurisdictionLevel", directoryFilters.jurisdictionLevel);
|
||||
}
|
||||
if (directoryFilters.q.trim()) {
|
||||
params.set("q", directoryFilters.q.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/legal/directory${params.toString() ? `?${params.toString()}` : ""}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.entities) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar el directorio.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectory(payload.entities);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEscalationCaseId) {
|
||||
void loadEscalation(selectedEscalationCaseId);
|
||||
}
|
||||
}, [selectedEscalationCaseId]);
|
||||
|
||||
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]">Casos abiertos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.openCases}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Alta severidad</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.highSeverityCases}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Monto en riesgo</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{kpis.amountAtRisk.toLocaleString("es-MX")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Casos resueltos</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.resolvedCases}</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}
|
||||
|
||||
<Tabs
|
||||
defaultTab="diagnosis"
|
||||
items={[
|
||||
{
|
||||
id: "diagnosis",
|
||||
label: "Diagnostico",
|
||||
content: (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-5">
|
||||
<p className="text-sm text-[#60718f]">Wizard de 4 pasos para sugerir severidad, tipo de caso y ruta de escalada.</p>
|
||||
<p className="text-xs font-semibold text-[#415a82]">Paso {diagnosisStep} de 4</p>
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Contrato vinculado (opcional)
|
||||
<select
|
||||
value={diagnosisContractId}
|
||||
onChange={(event) => setDiagnosisContractId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
disabled={Boolean(diagnosisId)}
|
||||
>
|
||||
<option value="">Sin contrato</option>
|
||||
{contractOptions.map((contract) => (
|
||||
<option key={contract.id} value={contract.id}>
|
||||
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Tipo de incumplimiento
|
||||
<select
|
||||
value={diagnosisAnswers.breachType}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, breachType: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="contract_breach">Incumplimiento contractual</option>
|
||||
<option value="payment_retention">Retencion de pagos</option>
|
||||
<option value="unjust_sanction">Sancion injusta</option>
|
||||
<option value="contract_dispute">Disputa contractual</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Estado procesal
|
||||
<select
|
||||
value={diagnosisAnswers.processStage}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, processStage: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="none">Sin proceso</option>
|
||||
<option value="informal_notice">Aviso informal</option>
|
||||
<option value="formal_notice">Requerimiento formal</option>
|
||||
<option value="ongoing_claim">Reclamacion en curso</option>
|
||||
<option value="trial">Via jurisdiccional</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Evidencia disponible
|
||||
<select
|
||||
value={diagnosisAnswers.evidenceLevel}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, evidenceLevel: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="none">Sin evidencia</option>
|
||||
<option value="basic">Basica</option>
|
||||
<option value="solid">Solida</option>
|
||||
<option value="robust">Robusta</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Urgencia
|
||||
<select
|
||||
value={diagnosisAnswers.urgency}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, urgency: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Objetivo legal
|
||||
<select
|
||||
value={diagnosisAnswers.objective}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, objective: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="payment">Recuperar pago</option>
|
||||
<option value="correction">Correccion</option>
|
||||
<option value="challenge">Impugnacion</option>
|
||||
<option value="mediation">Mediacion</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs font-semibold text-[#314764]">
|
||||
Monto en riesgo
|
||||
<input
|
||||
type="number"
|
||||
value={diagnosisAnswers.amountAtRisk}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, amountAtRisk: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={diagnosisAnswers.counterparty}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={diagnosisAnswers.description}
|
||||
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion del caso"
|
||||
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => submitDiagnosisStep(false)} disabled={isBusy}>
|
||||
Guardar paso
|
||||
</Button>
|
||||
<Button onClick={() => submitDiagnosisStep(true)} disabled={isBusy}>
|
||||
Finalizar y abrir caso
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{diagnosisRecommendation ? (
|
||||
<div className="rounded-xl border border-[#ced9ec] bg-[#f4f8ff] p-3 text-sm text-[#2d4b7b]">
|
||||
<p>
|
||||
Sugerencia: {diagnosisRecommendation.suggestedCaseType} / {diagnosisRecommendation.suggestedSeverity}
|
||||
</p>
|
||||
<p className="mt-1">Ruta: {diagnosisRecommendation.suggestedRoute.map((item) => item.title).join(" -> ")}</p>
|
||||
<p className="mt-1">Escritos: {diagnosisRecommendation.recommendedTemplates.join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cases",
|
||||
label: "Casos",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Dialog triggerLabel="Nuevo Caso" title="Nuevo Caso" description="Registro manual de caso legal">
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={newCase.contractId}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, contractId: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Sin contrato</option>
|
||||
{contractOptions.map((contract) => (
|
||||
<option key={contract.id} value={contract.id}>
|
||||
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={newCase.counterparty}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={newCase.description}
|
||||
onChange={(event) => setNewCase((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion"
|
||||
className="min-h-[90px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button onClick={createCase} disabled={isBusy}>
|
||||
Guardar caso
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{cases.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos registrados.</p> : null}
|
||||
|
||||
{cases.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.caseType}</p>
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityTone(item.severity)}`}>{item.severity}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.counterparty}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(event) => updateCaseStatus(item.id, event.target.value as LegalCaseView["status"])}
|
||||
className="h-8 rounded-lg border border-[#cfd8e7] px-2 text-xs"
|
||||
>
|
||||
<option value="OPEN">OPEN</option>
|
||||
<option value="IN_PROGRESS">IN_PROGRESS</option>
|
||||
<option value="ESCALATED">ESCALATED</option>
|
||||
<option value="RESOLVED">RESOLVED</option>
|
||||
<option value="CLOSED">CLOSED</option>
|
||||
</select>
|
||||
<span className="text-xs text-[#60718f]">Abierto: {formatDate(item.openedAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "escalation",
|
||||
label: "Escalada",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedEscalationCaseId}
|
||||
onChange={(event) => setSelectedEscalationCaseId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">Selecciona caso</option>
|
||||
{cases.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.caseType} - {item.counterparty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentEscalationCase && !escalation ? <Button variant="secondary" onClick={() => loadEscalation(currentEscalationCase.id)}>Cargar ruta</Button> : null}
|
||||
|
||||
{escalation?.steps.map((step) => (
|
||||
<article key={step.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{step.title}</p>
|
||||
<p className="text-xs text-[#60718f]">Ventana estimada: {step.estimatedWindow}</p>
|
||||
{step.requiresLawyer ? <p className="text-xs font-semibold text-[#9b4f2c]">Requiere abogado</p> : null}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs font-semibold text-[#314764]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(step.completedAt)}
|
||||
disabled={step.isBlocked}
|
||||
onChange={(event) => {
|
||||
if (!selectedEscalationCaseId) {
|
||||
return;
|
||||
}
|
||||
void toggleEscalationStep(selectedEscalationCaseId, step.key, event.target.checked, step.notes ?? "");
|
||||
}}
|
||||
/>
|
||||
Completado
|
||||
</label>
|
||||
</div>
|
||||
<ul className="mt-2 list-disc pl-5 text-xs text-[#60718f]">
|
||||
{step.checklist.map((item) => (
|
||||
<li key={`${step.key}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
label: "Escritos",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<select
|
||||
value={documentDraft.legalCaseId}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, legalCaseId: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Sin caso vinculado</option>
|
||||
{cases.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.caseType} - {item.counterparty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={documentDraft.templateKey}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, templateKey: event.target.value }))}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Plantilla automatica por tipo de caso</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.key} value={template.key}>
|
||||
{template.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={documentDraft.counterparty}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, counterparty: event.target.value }))}
|
||||
placeholder="Contraparte"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={documentDraft.companyName}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, companyName: event.target.value }))}
|
||||
placeholder="Empresa"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={documentDraft.objective}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, objective: event.target.value }))}
|
||||
placeholder="Objetivo legal"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={documentDraft.description}
|
||||
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Descripcion"
|
||||
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<Button onClick={generateDocument} disabled={isBusy}>
|
||||
Generar escrito
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generatedDocuments.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{formatDate(item.createdAt)}</p>
|
||||
<textarea readOnly value={item.content} className="mt-2 min-h-[160px] w-full rounded-lg border border-[#cfd8e7] p-2 text-xs" />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "directory",
|
||||
label: "Directorio",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={directoryFilters.jurisdictionLevel}
|
||||
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, jurisdictionLevel: event.target.value }))}
|
||||
className="h-10 rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
>
|
||||
<option value="">Todas las jurisdicciones</option>
|
||||
<option value="FEDERAL">FEDERAL</option>
|
||||
<option value="STATE">STATE</option>
|
||||
<option value="MUNICIPAL">MUNICIPAL</option>
|
||||
</select>
|
||||
<input
|
||||
value={directoryFilters.q}
|
||||
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, q: event.target.value }))}
|
||||
placeholder="Buscar entidad"
|
||||
className="h-10 min-w-[260px] rounded-lg border border-[#cfd8e7] px-3 text-sm"
|
||||
/>
|
||||
<Button variant="secondary" onClick={reloadDirectory}>
|
||||
Filtrar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{directory.length === 0 ? <p className="text-sm text-[#60718f]">Sin entidades en el filtro actual.</p> : null}
|
||||
|
||||
{directory.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">{item.name}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.jurisdictionLevel} - {item.scopeTags.join(", ")}</p>
|
||||
<p className="mt-1 text-xs text-[#60718f]">{item.websiteUrl ?? "Sin sitio web"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/app/licitation-card-actions.tsx
Normal file
93
src/components/app/licitation-card-actions.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LicitationReviewStatusView } from "@/lib/licitations/preferences";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type LicitationCardActionsProps = {
|
||||
licitationId: string;
|
||||
initialStatus: LicitationReviewStatusView;
|
||||
normativeAnalysisHref: string;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
status?: LicitationReviewStatusView;
|
||||
};
|
||||
|
||||
export function LicitationCardActions({ licitationId, initialStatus, normativeAnalysisHref }: LicitationCardActionsProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<LicitationReviewStatusView>(initialStatus);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function updateStatus(nextStatus: LicitationReviewStatusView, shouldRefresh = true) {
|
||||
setErrorMessage(null);
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/licitations/preferences/${encodeURIComponent(licitationId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.status) {
|
||||
setErrorMessage(payload.error ?? "No fue posible actualizar el estado.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus(payload.status);
|
||||
if (shouldRefresh) {
|
||||
router.refresh();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setErrorMessage("No fue posible actualizar el estado.");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleReviewed() {
|
||||
const nextStatus = status === "REVIEWED" ? "NEW" : "REVIEWED";
|
||||
await updateStatus(nextStatus);
|
||||
}
|
||||
|
||||
async function handleInterested() {
|
||||
const ok = await updateStatus("INTERESTED", false);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
window.location.href = normativeAnalysisHref;
|
||||
}
|
||||
|
||||
async function handleDiscardOrRestore() {
|
||||
const nextStatus = status === "DISCARDED" ? "NEW" : "DISCARDED";
|
||||
await updateStatus(nextStatus);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleToggleReviewed} disabled={isSaving}>
|
||||
{status === "REVIEWED" ? "Quitar consultada" : "Marcar consultada"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleInterested} disabled={isSaving}>
|
||||
Me interesa
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDiscardOrRestore} disabled={isSaving}>
|
||||
{status === "DISCARDED" ? "Restaurar" : "Descartar"}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage ? <p className="text-xs font-semibold text-[#b93a4b]">{errorMessage}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,11 @@ export function LicitationsSyncButton() {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({
|
||||
includePnt: true,
|
||||
includeLicitaya: true,
|
||||
force: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
@@ -25,6 +29,19 @@ export function LicitationsSyncButton() {
|
||||
error?: string;
|
||||
payload?: {
|
||||
processedMunicipalities?: number;
|
||||
processedScopes?: number;
|
||||
processedLicitayaBuckets?: number;
|
||||
results?: Array<{
|
||||
sourceResults?: Array<{
|
||||
status?: string;
|
||||
warnings?: string[];
|
||||
stats?: {
|
||||
totalFetched?: number;
|
||||
inserted?: number;
|
||||
updated?: number;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,8 +50,41 @@ export function LicitationsSyncButton() {
|
||||
return;
|
||||
}
|
||||
|
||||
const processed = payload.payload?.processedMunicipalities ?? 0;
|
||||
setMessage(`Sincronizacion completada. Municipios procesados: ${processed}.`);
|
||||
const processedMunicipalities = payload.payload?.processedMunicipalities ?? 0;
|
||||
const processedScopes = payload.payload?.processedScopes ?? processedMunicipalities;
|
||||
const licitayaBuckets = payload.payload?.processedLicitayaBuckets ?? 0;
|
||||
const sourceResults = payload.payload?.results?.flatMap((result) => result.sourceResults ?? []) ?? [];
|
||||
|
||||
const totals = sourceResults.reduce(
|
||||
(acc, sourceResult) => {
|
||||
acc.fetched += sourceResult.stats?.totalFetched ?? 0;
|
||||
acc.inserted += sourceResult.stats?.inserted ?? 0;
|
||||
acc.updated += sourceResult.stats?.updated ?? 0;
|
||||
acc.warnings += sourceResult.warnings?.length ?? 0;
|
||||
|
||||
if (sourceResult.status === "FAILED") {
|
||||
acc.failed += 1;
|
||||
} else if (sourceResult.status === "PARTIAL") {
|
||||
acc.partial += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
fetched: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
warnings: 0,
|
||||
failed: 0,
|
||||
partial: 0,
|
||||
},
|
||||
);
|
||||
|
||||
setMessage(
|
||||
`Sync listo. Municipios: ${processedMunicipalities}. Alcances: ${processedScopes}. LicitaYa: ${licitayaBuckets}. ` +
|
||||
`Detectados: ${totals.fetched}. Nuevos: ${totals.inserted}. Actualizados: ${totals.updated}. ` +
|
||||
`Parciales: ${totals.partial}. Fallidos: ${totals.failed}. Advertencias: ${totals.warnings}.`,
|
||||
);
|
||||
} catch {
|
||||
setMessage("No se pudo ejecutar la sincronizacion.");
|
||||
} finally {
|
||||
|
||||
@@ -43,6 +43,15 @@ type EvidenceDraft = {
|
||||
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
type AiQuestionSuggestion = {
|
||||
questionId: string;
|
||||
suggestedAnswerOptionId: string;
|
||||
rationale: string;
|
||||
missingEvidence: string[];
|
||||
confidence: number | null;
|
||||
suggestionId: string;
|
||||
};
|
||||
|
||||
function toEvidenceDraft(
|
||||
evidence: {
|
||||
notes: string;
|
||||
@@ -102,6 +111,11 @@ export function ModuleQuestionnaire({
|
||||
|
||||
const [saveStateByQuestionId, setSaveStateByQuestionId] = useState<Record<string, SaveState>>({});
|
||||
const [saveErrorByQuestionId, setSaveErrorByQuestionId] = useState<Record<string, string>>({});
|
||||
const [aiSuggestionsByQuestionId, setAiSuggestionsByQuestionId] = useState<Record<string, AiQuestionSuggestion>>({});
|
||||
const [isGeneratingAi, setIsGeneratingAi] = useState(false);
|
||||
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null);
|
||||
|
||||
const answeredCount = useMemo(() => {
|
||||
return questions.reduce((total, question) => {
|
||||
@@ -150,12 +164,14 @@ export function ModuleQuestionnaire({
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setQuestionSaveState(questionId, "error", payload.error ?? "No se pudo guardar la respuesta.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setQuestionSaveState(questionId, "saved");
|
||||
return true;
|
||||
} catch {
|
||||
setQuestionSaveState(questionId, "error", "No se pudo guardar la respuesta.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +207,118 @@ export function ModuleQuestionnaire({
|
||||
void persistQuestionResponse(questionId, answerOptionId, draft);
|
||||
}
|
||||
|
||||
async function setSuggestionDecision(suggestionId: string, decision: "accept" | "dismiss") {
|
||||
const response = await fetch(`/api/ai/suggestions/${encodeURIComponent(suggestionId)}/decision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
async function generateAiSuggestions() {
|
||||
setIsGeneratingAi(true);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/diagnostic/ai/suggestions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
moduleKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
suggestions?: AiQuestionSuggestion[];
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.suggestions) {
|
||||
setAiError(payload.error ?? "No fue posible generar sugerencias IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
const byQuestionId = payload.suggestions.reduce<Record<string, AiQuestionSuggestion>>((accumulator, suggestion) => {
|
||||
accumulator[suggestion.questionId] = suggestion;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
setAiSuggestionsByQuestionId(byQuestionId);
|
||||
setAiMessage(payload.suggestions.length ? "Sugerencias IA actualizadas." : "No hay preguntas pendientes para sugerir.");
|
||||
} catch {
|
||||
setAiError("No fue posible generar sugerencias IA.");
|
||||
} finally {
|
||||
setIsGeneratingAi(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAiSuggestion(questionId: string) {
|
||||
const suggestion = aiSuggestionsByQuestionId[questionId];
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActingSuggestionId(suggestion.suggestionId);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
setAnswers((previous) => ({
|
||||
...previous,
|
||||
[questionId]: suggestion.suggestedAnswerOptionId,
|
||||
}));
|
||||
|
||||
const draft = evidenceByQuestionId[questionId] ?? { notes: "", links: "" };
|
||||
const persisted = await persistQuestionResponse(questionId, suggestion.suggestedAnswerOptionId, draft);
|
||||
|
||||
if (!persisted) {
|
||||
setAiError("La sugerencia se aplico localmente, pero no se pudo guardar en el servidor.");
|
||||
return;
|
||||
}
|
||||
|
||||
await setSuggestionDecision(suggestion.suggestionId, "accept");
|
||||
setAiSuggestionsByQuestionId((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setAiMessage("Sugerencia IA aplicada.");
|
||||
} finally {
|
||||
setActingSuggestionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissAiSuggestion(questionId: string) {
|
||||
const suggestion = aiSuggestionsByQuestionId[questionId];
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActingSuggestionId(suggestion.suggestionId);
|
||||
setAiError(null);
|
||||
setAiMessage(null);
|
||||
|
||||
try {
|
||||
await setSuggestionDecision(suggestion.suggestionId, "dismiss");
|
||||
setAiSuggestionsByQuestionId((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setAiMessage("Sugerencia IA descartada.");
|
||||
} finally {
|
||||
setActingSuggestionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -231,6 +359,13 @@ export function ModuleQuestionnaire({
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={completion} showValue />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={isGeneratingAi} onClick={() => void generateAiSuggestions()}>
|
||||
{isGeneratingAi ? "Generando sugerencias IA..." : "Generar sugerencias IA"}
|
||||
</Button>
|
||||
{aiMessage ? <p className="text-sm text-[#1f7c4d]">{aiMessage}</p> : null}
|
||||
{aiError ? <p className="text-sm text-[#b63d3d]">{aiError}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -241,6 +376,9 @@ export function ModuleQuestionnaire({
|
||||
const evidenceDraft = evidenceByQuestionId[question.id] ?? { notes: "", links: "" };
|
||||
const saveState = saveStateByQuestionId[question.id] ?? "idle";
|
||||
const saveError = saveErrorByQuestionId[question.id] ?? null;
|
||||
const aiSuggestion = aiSuggestionsByQuestionId[question.id] ?? null;
|
||||
const suggestedOptionLabel =
|
||||
question.options.find((option) => option.id === aiSuggestion?.suggestedAnswerOptionId)?.label ?? "Opcion sugerida";
|
||||
|
||||
return (
|
||||
<div key={question.id} className="rounded-xl border border-[#d9e2f0] p-4">
|
||||
@@ -315,6 +453,41 @@ export function ModuleQuestionnaire({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{aiSuggestion ? (
|
||||
<div className="mt-4 rounded-lg border border-[#d7e2f4] bg-[#f6f9ff] p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Sugerencia IA</p>
|
||||
<p className="mt-1 text-sm text-[#405578]">
|
||||
Opcion sugerida: <span className="font-semibold">{suggestedOptionLabel}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#506688]">{aiSuggestion.rationale}</p>
|
||||
{aiSuggestion.missingEvidence.length ? (
|
||||
<p className="mt-1 text-xs text-[#60718f]">Evidencia faltante: {aiSuggestion.missingEvidence.join(" | ")}</p>
|
||||
) : null}
|
||||
{typeof aiSuggestion.confidence === "number" ? (
|
||||
<p className="mt-1 text-xs font-semibold text-[#334d77]">Confianza: {Math.round(aiSuggestion.confidence * 100)}%</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={actingSuggestionId === aiSuggestion.suggestionId}
|
||||
onClick={() => void applyAiSuggestion(question.id)}
|
||||
>
|
||||
Aplicar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={actingSuggestionId === aiSuggestion.suggestionId}
|
||||
onClick={() => void dismissAiSuggestion(question.id)}
|
||||
>
|
||||
Descartar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
1132
src/components/app/normative-analysis-view.tsx
Normal file
1132
src/components/app/normative-analysis-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { isAdminIdentity } from "@/lib/auth/admin";
|
||||
import { KontiaMark } from "@/components/app/kontia-mark";
|
||||
import { getCurrentUser } from "@/lib/auth/user";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getHeaderVisibility, type HeaderModeRequest } from "@/lib/layout/header";
|
||||
|
||||
type PageShellProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -10,98 +11,139 @@ type PageShellProps = {
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
showPageHeading?: boolean;
|
||||
headerMode?: HeaderModeRequest;
|
||||
headerBackHref?: string;
|
||||
headerBackLabel?: string;
|
||||
headerNextHref?: string;
|
||||
headerNextLabel?: string;
|
||||
headerShowManual?: boolean;
|
||||
headerPlanBadgeLabel?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
headerShowLogout?: boolean;
|
||||
contentWidth?: "default" | "wide";
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/diagnostic", label: "Diagnostico" },
|
||||
{ href: "/dashboard#modulos", label: "Modulos" },
|
||||
{ href: "/talleres-desarrollo", label: "Talleres" },
|
||||
{ href: "/licitations", label: "Licitaciones" },
|
||||
{ href: "/results", label: "Results" },
|
||||
{ href: "/recommendations", label: "Recommendations" },
|
||||
{ href: "/manual", label: "Manual" },
|
||||
];
|
||||
function HeaderBadge({ label }: { label: string }) {
|
||||
return <span className="rounded-full bg-[#0f2a5f] px-4 py-1.5 text-sm font-semibold text-white">{label}</span>;
|
||||
}
|
||||
|
||||
export async function PageShell({ children, title, description, action, className }: PageShellProps) {
|
||||
export async function PageShell({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
showPageHeading = true,
|
||||
headerMode = "auto",
|
||||
headerBackHref = "/dashboard",
|
||||
headerBackLabel = "Volver al Dashboard",
|
||||
headerNextHref,
|
||||
headerNextLabel,
|
||||
headerShowManual = false,
|
||||
headerPlanBadgeLabel,
|
||||
headerAction,
|
||||
headerShowLogout = true,
|
||||
contentWidth = "default",
|
||||
}: PageShellProps) {
|
||||
const currentUser = await getCurrentUser();
|
||||
const isAdmin = currentUser ? isAdminIdentity(currentUser.email, currentUser.role) : false;
|
||||
const visibility = getHeaderVisibility(Boolean(currentUser), headerMode);
|
||||
const containerClassName = contentWidth === "wide" ? "max-w-[1560px]" : "max-w-[1200px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eff2f7]">
|
||||
<header className="border-b border-[#d8dde7] bg-[#eff2f7]">
|
||||
<div className="mx-auto flex w-full max-w-[1200px] items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div className={cn("mx-auto flex w-full items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8", containerClassName)}>
|
||||
{visibility.showBackLink ? (
|
||||
<Link href={headerBackHref} className="inline-flex items-center gap-3 text-3xl font-semibold text-[#1a2c4f] [font-family:var(--font-display)]">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
←
|
||||
</span>
|
||||
<span className="text-base font-semibold [font-family:var(--font-sans)]">{headerBackLabel}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
<KontiaMark />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<nav className="hidden items-center gap-5 md:flex">
|
||||
{currentUser ? (
|
||||
navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Link href="/#metodologia" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
{visibility.showMarketingNav ? (
|
||||
<nav className="hidden items-center gap-10 text-base font-medium text-[#4f5f7d] lg:flex">
|
||||
<Link href="/#metodologia" className="transition-colors hover:text-[#223f7c]">
|
||||
Metodologia
|
||||
</Link>
|
||||
<Link href="/#modulos" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
<Link href="/#modulos" className="transition-colors hover:text-[#223f7c]">
|
||||
Modulos
|
||||
</Link>
|
||||
<Link href="/#beneficios" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
|
||||
<Link href="/#beneficios" className="transition-colors hover:text-[#223f7c]">
|
||||
Beneficios
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && currentUser ? (
|
||||
<Link href="/admin" className="text-sm font-semibold text-[#1f3f82] transition-colors hover:text-[#17336c]">
|
||||
<Link href="/#contacto" className="transition-colors hover:text-[#223f7c]">
|
||||
Contacto
|
||||
</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{visibility.showUserControls && headerShowManual ? (
|
||||
<Link href="/manual" className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[#cfd8e7] bg-[#f7f9fd] px-5 text-sm font-semibold text-[#1a2d51] transition hover:bg-white">
|
||||
<span aria-hidden>◧</span>
|
||||
Manual
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{visibility.showUserControls && headerNextHref && headerNextLabel ? (
|
||||
<Link href={headerNextHref} className="inline-flex items-center gap-2 text-sm font-semibold text-[#445d86] hover:text-[#1f3f84]">
|
||||
{headerNextLabel}
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{visibility.showUserControls && headerAction ? headerAction : null}
|
||||
{visibility.showUserControls && headerPlanBadgeLabel ? <HeaderBadge label={headerPlanBadgeLabel} /> : null}
|
||||
|
||||
{isAdmin && currentUser && visibility.mode === "app" ? (
|
||||
<Link href="/admin" className="rounded-xl border border-[#cfd8e7] px-3 py-2 text-sm font-semibold text-[#2f486f] transition-colors hover:bg-white">
|
||||
Admin
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{currentUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="hidden text-xs font-semibold text-[#5f6c84] sm:block">{currentUser.email}</p>
|
||||
{visibility.showUserControls && headerShowLogout ? (
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl border border-[#ccd6e5] px-3 py-1.5 text-xs font-semibold text-[#334a73] transition-colors hover:bg-white"
|
||||
className="inline-flex h-11 items-center rounded-2xl border border-[#ccd6e5] px-4 text-sm font-semibold text-[#334a73] transition-colors hover:bg-white"
|
||||
>
|
||||
Logout
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-[#5f6c84]">
|
||||
) : null}
|
||||
|
||||
{visibility.showAuthButtons ? (
|
||||
<>
|
||||
<Link href="/login" className="rounded-xl px-3 py-2 text-sm font-semibold text-[#13254a] transition-colors hover:text-[#214485]">
|
||||
Iniciar Sesion
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]"
|
||||
>
|
||||
<Link href="/register" className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]">
|
||||
Comenzar Gratis
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[1200px] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<main className={cn("mx-auto w-full px-4 py-8 sm:px-6 lg:px-8", containerClassName)}>
|
||||
{showPageHeading ? (
|
||||
<section className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-5xl">{title}</h1>
|
||||
<h1 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-4xl">{title}</h1>
|
||||
{description ? <p className="mt-1 text-sm text-[#60718f]">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div>{action}</div> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={cn("space-y-4", className)}>{children}</section>
|
||||
</main>
|
||||
|
||||
779
src/components/app/preventive-dossier-view.tsx
Normal file
779
src/components/app/preventive-dossier-view.tsx
Normal file
@@ -0,0 +1,779 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AUDIT_QUESTIONNAIRE } from "@/lib/audits/scoring";
|
||||
import type {
|
||||
AuditKpiSnapshot,
|
||||
AuditSimulationView,
|
||||
InstitutionalDossierFreshness,
|
||||
InstitutionalDossierLoadStrategy,
|
||||
InstitutionalDossierPayload,
|
||||
} from "@/lib/audits/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 PreventiveDossierViewProps = {
|
||||
initialSimulations: AuditSimulationView[];
|
||||
initialKpis: AuditKpiSnapshot;
|
||||
initialDossier: InstitutionalDossierPayload | null;
|
||||
initialFreshness: InstitutionalDossierFreshness | null;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
simulations?: AuditSimulationView[];
|
||||
simulation?: AuditSimulationView;
|
||||
kpis?: AuditKpiSnapshot;
|
||||
dossier?: InstitutionalDossierPayload;
|
||||
freshness?: InstitutionalDossierFreshness;
|
||||
};
|
||||
|
||||
type AuditAiFindings = {
|
||||
auditorLikelyFindings: {
|
||||
area: string;
|
||||
finding: string;
|
||||
severity: "alto" | "medio" | "bajo";
|
||||
}[];
|
||||
missingEvidence: string[];
|
||||
topRisks: string[];
|
||||
remediationPlan: {
|
||||
step: string;
|
||||
priority: "alta" | "media" | "baja";
|
||||
ownerSuggestion: string;
|
||||
targetDate: string;
|
||||
}[];
|
||||
confidence: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
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.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`;
|
||||
}
|
||||
|
||||
export function PreventiveDossierView({ initialSimulations, initialKpis, initialDossier, initialFreshness }: PreventiveDossierViewProps) {
|
||||
const [simulations, setSimulations] = useState(initialSimulations);
|
||||
const [kpis, setKpis] = useState(initialKpis);
|
||||
const [dossier, setDossier] = useState(initialDossier);
|
||||
const [freshness, setFreshness] = useState<InstitutionalDossierFreshness | null>(initialFreshness);
|
||||
const [selectedSimulationId, setSelectedSimulationId] = useState(initialSimulations[0]?.id ?? "");
|
||||
|
||||
const [newSimulation, setNewSimulation] = useState({
|
||||
name: "",
|
||||
auditType: "Preventiva integral",
|
||||
});
|
||||
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(AUDIT_QUESTIONNAIRE.map((question) => [question.key, "si"])),
|
||||
);
|
||||
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoadingAiFindings, setIsLoadingAiFindings] = useState(false);
|
||||
const [aiFindings, setAiFindings] = useState<AuditAiFindings | null>(null);
|
||||
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
|
||||
|
||||
const continuityWarnings = useMemo(() => {
|
||||
if (!dossier) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const missingComponents = dossier.components
|
||||
.filter((component) => component.totalItems > 0 && component.withEvidence < component.totalItems)
|
||||
.map((component) => `Continuidad incompleta en ${component.label}: ${component.withEvidence}/${component.totalItems} con evidencia.`);
|
||||
|
||||
return missingComponents;
|
||||
}, [dossier]);
|
||||
|
||||
const selectedSimulation = useMemo(
|
||||
() => simulations.find((item) => item.id === selectedSimulationId) ?? null,
|
||||
[simulations, selectedSimulationId],
|
||||
);
|
||||
|
||||
async function reloadSimulations() {
|
||||
const response = await fetch("/api/audits/simulations");
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulations || !payload.kpis) {
|
||||
throw new Error(payload.error ?? "No fue posible recargar simulaciones.");
|
||||
}
|
||||
|
||||
setSimulations(payload.simulations);
|
||||
setKpis(payload.kpis);
|
||||
|
||||
if (!selectedSimulationId && payload.simulations[0]?.id) {
|
||||
setSelectedSimulationId(payload.simulations[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSimulation() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/simulations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(newSimulation),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible crear la simulacion.");
|
||||
return;
|
||||
}
|
||||
|
||||
setNewSimulation({
|
||||
name: "",
|
||||
auditType: "Preventiva integral",
|
||||
});
|
||||
setSelectedSimulationId(payload.simulation.id);
|
||||
setMessage("Simulacion creada.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible crear la simulacion.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function scoreSimulation() {
|
||||
if (!selectedSimulationId) {
|
||||
setErrorMessage("Selecciona una simulacion para calificar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/audits/simulations/${encodeURIComponent(selectedSimulationId)}/score`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
responses: AUDIT_QUESTIONNAIRE.map((question) => ({
|
||||
questionKey: question.key,
|
||||
answer: answers[question.key] ?? "si",
|
||||
evidenceRefs: [],
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.simulation) {
|
||||
setErrorMessage(payload.error ?? "No fue posible calcular la simulacion.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Simulacion calificada.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible calcular la simulacion.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDossier(strategy: InstitutionalDossierLoadStrategy) {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/audits/expediente?strategy=${encodeURIComponent(strategy)}`);
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.dossier || !payload.freshness) {
|
||||
setErrorMessage(payload.error ?? "No fue posible cargar el expediente.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDossier(payload.dossier);
|
||||
setFreshness(payload.freshness);
|
||||
setMessage(
|
||||
payload.freshness.source === "snapshot"
|
||||
? "Expediente cargado desde snapshot."
|
||||
: "Expediente cargado en modo vivo.",
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage("No fue posible cargar el expediente.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDossier() {
|
||||
setIsBusy(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/expediente/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.dossier) {
|
||||
setErrorMessage(payload.error ?? "No fue posible refrescar el expediente.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDossier(payload.dossier);
|
||||
setFreshness(payload.freshness ?? null);
|
||||
setMessage("Snapshot del expediente institucional actualizado.");
|
||||
await reloadSimulations();
|
||||
} catch {
|
||||
setErrorMessage("No fue posible refrescar el expediente.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setSuggestionDecision(decision: "accept" | "dismiss") {
|
||||
if (!aiSuggestionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`/api/ai/suggestions/${encodeURIComponent(aiSuggestionId)}/decision`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
}
|
||||
|
||||
async function generateAiFindings() {
|
||||
setIsLoadingAiFindings(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/audits/ai/findings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
simulationId: selectedSimulationId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
auditorLikelyFindings?: AuditAiFindings["auditorLikelyFindings"];
|
||||
missingEvidence?: AuditAiFindings["missingEvidence"];
|
||||
topRisks?: AuditAiFindings["topRisks"];
|
||||
remediationPlan?: AuditAiFindings["remediationPlan"];
|
||||
confidence?: AuditAiFindings["confidence"];
|
||||
suggestionId?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
setErrorMessage(payload.error ?? "No fue posible generar dictamen IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiFindings({
|
||||
auditorLikelyFindings: payload.auditorLikelyFindings ?? [],
|
||||
missingEvidence: payload.missingEvidence ?? [],
|
||||
topRisks: payload.topRisks ?? [],
|
||||
remediationPlan: payload.remediationPlan ?? [],
|
||||
confidence: payload.confidence ?? "low",
|
||||
});
|
||||
setAiSuggestionId(payload.suggestionId ?? null);
|
||||
setMessage("Dictamen IA generado.");
|
||||
} catch {
|
||||
setErrorMessage("No fue posible generar dictamen IA.");
|
||||
} finally {
|
||||
setIsLoadingAiFindings(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]">Auditorias completadas</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.completedAudits}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Ultima calificacion</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.lastScore ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Contratos registrados</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.registeredContracts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">PDFs resguardados</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.securedPdfs}</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}
|
||||
|
||||
<Tabs
|
||||
defaultTab="simulator"
|
||||
items={[
|
||||
{
|
||||
id: "simulator",
|
||||
label: "Simulador",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<Dialog triggerLabel="Nueva Simulacion" title="Nueva Simulacion" description="Configura simulacion de auditoria preventiva">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={newSimulation.name}
|
||||
onChange={(event) => setNewSimulation((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Nombre"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
|
||||
/>
|
||||
<input
|
||||
value={newSimulation.auditType}
|
||||
onChange={(event) => setNewSimulation((prev) => ({ ...prev, auditType: event.target.value }))}
|
||||
placeholder="Tipo de auditoria"
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
|
||||
/>
|
||||
<Button onClick={createSimulation} disabled={isBusy}>
|
||||
Guardar simulacion
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<select
|
||||
value={selectedSimulationId}
|
||||
onChange={(event) => setSelectedSimulationId(event.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">Selecciona simulacion</option>
|
||||
{simulations.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Cuestionario de scoring</p>
|
||||
{AUDIT_QUESTIONNAIRE.map((question) => (
|
||||
<label key={question.key} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#dbe3f1] px-3 py-2 text-sm">
|
||||
<span className="text-[#2e466e]">{question.prompt}</span>
|
||||
<select
|
||||
value={answers[question.key] ?? "si"}
|
||||
onChange={(event) =>
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[question.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 rounded-lg border border-[#cfd8e7] bg-white px-2 text-xs"
|
||||
>
|
||||
<option value="si">Si</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<Button onClick={scoreSimulation} disabled={isBusy || !selectedSimulationId}>
|
||||
Calificar simulacion
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedSimulation ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Resultado actual</p>
|
||||
<p className="text-sm text-[#60718f]">Status: {selectedSimulation.status}</p>
|
||||
<p className="text-sm text-[#60718f]">Score: {selectedSimulation.overallScore ?? 0}</p>
|
||||
<p className="text-sm text-[#60718f]">Completada: {formatDate(selectedSimulation.completedAt)}</p>
|
||||
{selectedSimulation.sections.map((section) => (
|
||||
<article key={section.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{section.key}</p>
|
||||
<p>Status: {section.status}</p>
|
||||
<p>Score: {section.score ?? 0}</p>
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dossier",
|
||||
label: "Expediente",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void loadDossier("live")} disabled={isBusy}>
|
||||
Cargar modo vivo (default)
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadDossier("snapshot")} disabled={isBusy}>
|
||||
Cargar ultimo snapshot
|
||||
</Button>
|
||||
<Button onClick={refreshDossier} disabled={isBusy}>
|
||||
Generar nuevo snapshot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{freshness ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5 text-sm text-[#385073]">
|
||||
<p>
|
||||
Estrategia solicitada: <strong>{freshness.strategy}</strong> | Fuente actual: <strong>{freshness.source}</strong>
|
||||
</p>
|
||||
<p>Generado: {formatDate(freshness.generatedAt)}</p>
|
||||
<p>
|
||||
Estado de frescura:{" "}
|
||||
<span className={freshness.isStale ? "font-semibold text-[#ad3f3f]" : "font-semibold text-[#1f7f4f]"}>
|
||||
{freshness.isStale ? "Desactualizado" : "Vigente"}
|
||||
</span>{" "}
|
||||
(umbral: {freshness.staleAfterMinutes} min)
|
||||
</p>
|
||||
{freshness.snapshotId ? <p>Snapshot ID: {freshness.snapshotId}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{continuityWarnings.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#8d6308]">Advertencias de continuidad</p>
|
||||
{continuityWarnings.map((warning) => (
|
||||
<p key={warning} className="rounded-lg border border-[#f0deb0] bg-[#fff8e9] px-2 py-1 text-xs text-[#8d6308]">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!dossier ? <p className="text-sm text-[#60718f]">Sin expediente generado todavia.</p> : null}
|
||||
|
||||
{dossier ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="grid gap-2 py-5 text-sm text-[#385073] md:grid-cols-3 xl:grid-cols-4">
|
||||
<p>Contratos: {dossier.summary.contracts}</p>
|
||||
<p>PDF contratos: {dossier.summary.contractDocuments}</p>
|
||||
<p>Propuestas: {dossier.summary.proposals}</p>
|
||||
<p>PDF propuestas: {dossier.summary.proposalDocuments}</p>
|
||||
<p>Casos legales: {dossier.summary.legalCases}</p>
|
||||
<p>Escritos legales: {dossier.summary.legalDocuments}</p>
|
||||
<p>Alertas alto riesgo: {dossier.summary.highRiskAlerts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{dossier.components.map((component) => (
|
||||
<article key={component.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3 text-sm">
|
||||
<p className="font-semibold text-[#1d335a]">{component.label}</p>
|
||||
<p className="mt-1 text-[#60718f]">Estado: {component.status}</p>
|
||||
<p className="mt-1 text-[#60718f]">
|
||||
Evidencia: {component.withEvidence}/{component.totalItems}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Propuestas en expediente (M5)</p>
|
||||
{dossier.proposals.length === 0 ? <p className="text-sm text-[#60718f]">Sin propuestas.</p> : null}
|
||||
{dossier.proposals.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p>
|
||||
{item.status} | Documentos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Contratos en expediente (M8)</p>
|
||||
{dossier.contracts.length === 0 ? <p className="text-sm text-[#60718f]">Sin contratos.</p> : null}
|
||||
{dossier.contracts.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p>
|
||||
{item.status} | Entregables {item.deliverables} | Pagos {item.payments} | Documentos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.kind ? `${document.kind} | ` : ""}
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-5">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Casos legales en expediente (M9)</p>
|
||||
{dossier.legal.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos legales.</p> : null}
|
||||
{dossier.legal.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
|
||||
<p className="font-semibold">
|
||||
{item.caseType} | {item.severity}
|
||||
</p>
|
||||
<p>
|
||||
{item.status} | Escritos {item.documents}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{item.links.map((link) => (
|
||||
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{item.documentEntries.length > 0 ? (
|
||||
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
|
||||
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{item.documentEntries.map((document) => (
|
||||
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
|
||||
<p className="font-semibold">{document.fileName}</p>
|
||||
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
|
||||
Por que este archivo
|
||||
</p>
|
||||
<p>
|
||||
{document.kind ? `${document.kind} | ` : ""}
|
||||
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
|
||||
</p>
|
||||
<p className="mt-1">{document.traceLabel}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{document.links.map((link) => (
|
||||
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dictamen-ia",
|
||||
label: "Dictamen IA",
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isLoadingAiFindings} onClick={() => void generateAiFindings()}>
|
||||
{isLoadingAiFindings ? "Generando dictamen IA..." : "Generar dictamen IA"}
|
||||
</Button>
|
||||
{aiFindings ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("dismiss");
|
||||
setAiFindings(null);
|
||||
setMessage("Dictamen IA descartado.");
|
||||
}}
|
||||
>
|
||||
Descartar dictamen IA
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!aiFindings ? <p className="text-sm text-[#60718f]">Genera dictamen IA para simular observaciones de auditor.</p> : null}
|
||||
|
||||
{aiFindings ? (
|
||||
<>
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Hallazgos probables del auditor</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiFindings.auditorLikelyFindings.map((item) => (
|
||||
<li key={`${item.area}-${item.finding}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">
|
||||
{item.area} · {item.severity}
|
||||
</p>
|
||||
<p>{item.finding}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Evidencia faltante</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiFindings.missingEvidence.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Top riesgos</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
|
||||
{aiFindings.topRisks.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
|
||||
<p className="text-sm font-semibold text-[#1d335a]">Plan de remediacion</p>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
|
||||
{aiFindings.remediationPlan.map((item) => (
|
||||
<li key={`${item.step}-${item.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
|
||||
<p className="font-semibold text-[#233b62]">{item.step}</p>
|
||||
<p>
|
||||
Prioridad {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDate}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setSuggestionDecision("accept");
|
||||
setMessage("Dictamen IA marcado como aceptado.");
|
||||
}}
|
||||
>
|
||||
Aceptar dictamen IA
|
||||
</Button>
|
||||
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiFindings.confidence}</p>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1850
src/components/app/proposal-workflow-view.tsx
Normal file
1850
src/components/app/proposal-workflow-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user