Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +45 -40
- .gitattributes +1 -0
- .gitignore +25 -0
- .gitmodules +0 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +1 -0
- AGENTS.md +8 -0
- TenderHub Kenya Build Plan.md +66 -0
- WORKERS_ARCHITECTURE.md +438 -0
- apps/web/.gitignore +2 -0
- apps/web/e2e/basic.spec.ts +15 -0
- apps/web/eslint.config.mjs +5 -0
- apps/web/jest.config.cjs +28 -0
- apps/web/jest.setup.js +37 -0
- apps/web/netlify.toml +6 -0
- apps/web/next-env.d.ts +6 -0
- apps/web/next.config.ts +14 -0
- apps/web/package.json +92 -0
- apps/web/playwright.config.ts +28 -0
- apps/web/postcss.config.js +6 -0
- apps/web/public/icons/icon-192.png +0 -0
- apps/web/public/icons/icon-512.png +0 -0
- apps/web/public/manifest.json +27 -0
- apps/web/public/sw.js +1 -0
- apps/web/public/workbox-b52a85cb.js +1 -0
- apps/web/scratch/check_user.cjs +31 -0
- apps/web/scratch/delete_user.cjs +37 -0
- apps/web/scratch/get_org_id.cjs +51 -0
- apps/web/scratch/setup_org.cjs +42 -0
- apps/web/src/__tests__/api/mpesa-stk.test.ts +18 -0
- apps/web/src/__tests__/api/tenders-drafts-export.test.ts +14 -0
- apps/web/src/__tests__/api/tenders-unlock.test.ts +16 -0
- apps/web/src/__tests__/api/uploads-init.test.ts +18 -0
- apps/web/src/app/(dashboard)/consultant/page.tsx +118 -0
- apps/web/src/app/(dashboard)/dashboard/page.tsx +170 -0
- apps/web/src/app/(dashboard)/layout.tsx +29 -0
- apps/web/src/app/(dashboard)/proposals/[id]/page.tsx +203 -0
- apps/web/src/app/(dashboard)/proposals/page.tsx +68 -0
- apps/web/src/app/(dashboard)/settings/page.tsx +223 -0
- apps/web/src/app/(dashboard)/tenders/[id]/page.tsx +276 -0
- apps/web/src/app/(dashboard)/tenders/page.tsx +113 -0
- apps/web/src/app/(dashboard)/upload/page.tsx +217 -0
- apps/web/src/app/admin/layout.tsx +249 -0
- apps/web/src/app/admin/page.tsx +590 -0
- apps/web/src/app/api/admin/activity/route.ts +68 -0
- apps/web/src/app/api/admin/check/route.ts +41 -0
- apps/web/src/app/api/admin/jobs/retry/route.ts +74 -0
- apps/web/src/app/api/admin/jobs/route.ts +53 -0
.env.example
CHANGED
|
@@ -1,40 +1,45 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
# Supabase
|
| 5 |
-
NEXT_PUBLIC_SUPABASE_URL=https://
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
# Optional
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
#
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
# Optional
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Web
|
| 2 |
+
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
| 3 |
+
|
| 4 |
+
# Supabase
|
| 5 |
+
NEXT_PUBLIC_SUPABASE_URL=https://weeiosqgiyjeeftvpyjr.supabase.co
|
| 6 |
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndlZWlvc3FnaXlqZWVmdHZweWpyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU1NzA4NTQsImV4cCI6MjA5MTE0Njg1NH0.15tz39svACp7QZZJeyVVDzhU-IuXwlW6BG5RZQDF97g
|
| 7 |
+
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndlZWlvc3FnaXlqZWVmdHZweWpyIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NTU3MDg1NCwiZXhwIjoyMDkxMTQ2ODU0fQ.rs0jJRVhEOGrdUDXvGm_a5M9Q717w0I56HddiD8m2WE
|
| 8 |
+
SUPABASE_STORAGE_BUCKET=TenderDocs
|
| 9 |
+
# Local dev identity fallback (must be valid UUIDs)
|
| 10 |
+
DEV_ORGANIZATION_ID=
|
| 11 |
+
DEV_USER_ID=
|
| 12 |
+
DEV_AUTO_BOOTSTRAP=1
|
| 13 |
+
INLINE_PROCESSING_ENABLED=0
|
| 14 |
+
|
| 15 |
+
# Worker
|
| 16 |
+
DATABASE_URL=
|
| 17 |
+
# Optional fallback IP for DATABASE_URL host resolution issues
|
| 18 |
+
DATABASE_HOSTADDR=
|
| 19 |
+
WORKER_POLL_INTERVAL_SECONDS=5
|
| 20 |
+
WORKER_RUN_ONCE=0
|
| 21 |
+
|
| 22 |
+
# LLM Configuration
|
| 23 |
+
LLM_ENABLED=true
|
| 24 |
+
LLM_PRIMARY_MODEL=claude-3-5-sonnet-20241022
|
| 25 |
+
LLM_FALLBACK_MODELS=gpt-4.1-mini,gpt-3.5-turbo
|
| 26 |
+
LLM_TIMEOUT_SECONDS=60.0
|
| 27 |
+
|
| 28 |
+
# AI Gateway (LiteLLM compatible)
|
| 29 |
+
LITELLM_BASE_URL=http://localhost:4000
|
| 30 |
+
LITELLM_API_KEY=
|
| 31 |
+
LITELLM_EXTRACT_MODEL=anthropic/claude-3-5-sonnet
|
| 32 |
+
LITELLM_DRAFT_MODEL=openai/gpt-4.1-mini
|
| 33 |
+
# Optional comma-separated fallback chain for draft generation retries
|
| 34 |
+
LITELLM_DRAFT_FALLBACK_MODELS=
|
| 35 |
+
# Retry controls for transient provider failures / rate limits
|
| 36 |
+
LITELLM_RETRY_MAX_ATTEMPTS=2
|
| 37 |
+
LITELLM_RETRY_BACKOFF_MS=500
|
| 38 |
+
LITELLM_REQUEST_TIMEOUT_MS=45000
|
| 39 |
+
|
| 40 |
+
# Billing
|
| 41 |
+
MPESA_CONSUMER_KEY=
|
| 42 |
+
MPESA_CONSUMER_SECRET=
|
| 43 |
+
MPESA_PASSKEY=
|
| 44 |
+
MPESA_SHORTCODE=
|
| 45 |
+
MPESA_CALLBACK_URL=
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
tender-win-engine/bun.lockb filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
out
|
| 4 |
+
dist
|
| 5 |
+
build
|
| 6 |
+
.turbo
|
| 7 |
+
.vercel
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.*.local
|
| 11 |
+
*.log
|
| 12 |
+
coverage
|
| 13 |
+
tmp
|
| 14 |
+
temp
|
| 15 |
+
apps/web/.next
|
| 16 |
+
apps/web/node_modules
|
| 17 |
+
packages/schemas/node_modules
|
| 18 |
+
.venv
|
| 19 |
+
__pycache__
|
| 20 |
+
*.pyc
|
| 21 |
+
*.egg-info
|
| 22 |
+
.git-credentials
|
| 23 |
+
|
| 24 |
+
.vercel
|
| 25 |
+
.env*.local
|
.gitmodules
ADDED
|
File without changes
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
AGENTS.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Context & Rules
|
| 2 |
+
This file contains rules, project knowledge, and context for AI agents working on this project. Future agent iterations will read this to maintain context.
|
| 3 |
+
|
| 4 |
+
## Discovered Patterns
|
| 5 |
+
- (None yet)
|
| 6 |
+
|
| 7 |
+
## Gotchas
|
| 8 |
+
- (None yet)
|
TenderHub Kenya Build Plan.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TenderHub Kenya Build Plan
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
- Build one product first: a mobile-first Tender-Winning Engine for Kenyan SMEs that starts with an uploaded tender PDF and ends with a compliance matrix, bid/no-bid recommendation, and editable proposal draft.
|
| 5 |
+
- Optimize for a solo founder: keep the product surface narrow, avoid scraper-heavy scope, and ship an upload-first workflow that proves people will pay for analysis and drafting.
|
| 6 |
+
- Prioritize open-source building blocks where they reduce lock-in or variable cost, but use managed hosting where it materially improves speed and reliability.
|
| 7 |
+
|
| 8 |
+
## Product Plan
|
| 9 |
+
- Launch wedge: self-serve Kenyan contractors, not full consultant white-label. This keeps onboarding, permissions, and support simple while still solving the highest-value workflow.
|
| 10 |
+
- Free experience: sign up, create company profile, upload PDF, and see a preview with procuring entity, deadline, tender category, and top blockers.
|
| 11 |
+
- Paid experience: unlock full mandatory-requirements matrix, risk summary, bid/no-bid score, and editable draft sections.
|
| 12 |
+
- Output format: generate DOCX first, not PDF. Users will edit proposals before submission, and DOCX is cheaper and more reliable to produce.
|
| 13 |
+
- Explicitly defer from v1: large-scale portal scraping, full white-label branding, shared team workspaces, and deep consultant collaboration flows.
|
| 14 |
+
- Build the data model with `organizations` and `company_profiles` now so consultant and white-label expansion does not require a painful migration later.
|
| 15 |
+
|
| 16 |
+
## Architecture And Tooling
|
| 17 |
+
- Frontend: Next.js 15 PWA with App Router, mobile-first dashboard, resumable uploads, background job polling, and offline-friendly caching for weak mobile connections.
|
| 18 |
+
- Core data layer: Supabase Cloud for PostgreSQL, Auth, Storage, and pgvector. This is open-core, fast to launch, and good enough until volume justifies more self-hosting.
|
| 19 |
+
- Backend split: TypeScript for the web/control plane and Python for document processing. This is the best speed-to-quality tradeoff because PDF/OCR tooling is materially stronger in Python.
|
| 20 |
+
- Repo shape: monorepo with `apps/web`, `apps/worker`, and `packages/schemas` for shared request/response and extraction contracts.
|
| 21 |
+
- Queue/orchestration: Postgres-backed job system using a `processing_jobs` table, retries, idempotency keys, and `FOR UPDATE SKIP LOCKED`. Do not add Redis or Temporal in v1.
|
| 22 |
+
- Storage: original PDFs, OCR-normalized PDFs, extracted artifacts, and generated DOCX files in Supabase Storage with signed access URLs.
|
| 23 |
+
- Observability: PostHog for product analytics, Sentry for exceptions, OpenTelemetry-based structured logging for long-running jobs.
|
| 24 |
+
|
| 25 |
+
## AI Engine
|
| 26 |
+
- Put an internal AI gateway between the app and model vendors. Use LiteLLM so the product talks to one interface and can switch between Anthropic, OpenAI, Gemini, OpenRouter, vLLM, or Ollama later.
|
| 27 |
+
- Define task contracts, not provider-specific calls: `extractTenderStructure`, `generateComplianceMatrix`, `scoreBid`, `draftProposalSection`, and `embedChunks`.
|
| 28 |
+
- Keep prompts versioned and schema-bound. Every model response must parse into strict typed JSON with evidence spans such as page number and source chunk ID.
|
| 29 |
+
- Use deterministic scoring for hard blockers and most of the bid/no-bid result. The LLM should explain and synthesize, not decide mandatory eligibility on its own.
|
| 30 |
+
- Primary extraction path: PyMuPDF for text extraction and rendering, selective `pdfplumber` or Camelot for tables, and OCRmyPDF plus Tesseract only when the file lacks a usable text layer.
|
| 31 |
+
- Retrieval layer: normalize the tender into sections, chunk by semantic headings, store embeddings in pgvector, and anchor every generated output to retrieved evidence.
|
| 32 |
+
- Embeddings: run a local open-source model such as `bge-small-en-v1.5` or `e5-base-v2` through FastEmbed/ONNX in the worker so chunking and retrieval do not consume third-party API quota.
|
| 33 |
+
- Model policy: use a strong frontier model for extraction and legal-style reasoning, a cheaper fallback model for retries and lower-value drafting, and reserve local open-source LLMs for later experimentation rather than core extraction quality on day one.
|
| 34 |
+
- Rate-limit strategy: cache by file hash, run extraction once per document, reuse structured results for scoring and drafting, generate drafts section-by-section, and let LiteLLM handle retry/fallback for 429s and timeouts.
|
| 35 |
+
|
| 36 |
+
## Data Model And Interfaces
|
| 37 |
+
- Core tables: `organizations`, `members`, `company_profiles`, `tenders`, `documents`, `processing_jobs`, `extractions`, `compliance_items`, `bid_scores`, `drafts`, `payments`, `usage_events`, `provider_logs`.
|
| 38 |
+
- `CompanyProfile` should include legal entity details, AGPO status, KRA/VAT status, registration classes such as NCA, operating counties, past projects, team capacity, and reusable proposal facts.
|
| 39 |
+
- `TenderExtraction` should include procuring entity, deadline, mandatory documents, technical scope, evaluation criteria, commercial terms, penalty clauses, ambiguity flags, and evidence spans.
|
| 40 |
+
- `ComplianceItem` should include requirement text, severity, pass/fail/unknown state, evidence pointer, and remediation guidance.
|
| 41 |
+
- `BidScore` should include weighted factors, hard blockers, explanation, confidence, and explicit reason codes that the UI can render.
|
| 42 |
+
- Public APIs: `POST /api/uploads/init`, `POST /api/tenders/:id/process`, `GET /api/tenders/:id/status`, `GET /api/tenders/:id/preview`, `POST /api/tenders/:id/unlock`, `GET /api/tenders/:id/analysis`, `POST /api/tenders/:id/drafts`, and `POST /api/payments/mpesa/callback`.
|
| 43 |
+
- All schemas should be shared between web and worker so model swaps do not change the product contract.
|
| 44 |
+
|
| 45 |
+
## Delivery Plan
|
| 46 |
+
- Week 1: set up monorepo, Supabase project, auth, org/company-profile schema, storage buckets, upload flow, preview UI shell, and job-state plumbing.
|
| 47 |
+
- Week 2: build the extraction pipeline, OCR fallback, strict JSON parsing, evidence-linked preview, local embeddings, and pgvector retrieval.
|
| 48 |
+
- Week 3: implement blocker detection, bid/no-bid scoring, compliance matrix UI, draft generation, usage metering, and paywall boundaries.
|
| 49 |
+
- Week 4: integrate M-Pesa STK Push and webhook reconciliation, harden retries and provider fallback, add analytics/monitoring, and run a manual-review-assisted beta.
|
| 50 |
+
|
| 51 |
+
## Test Plan
|
| 52 |
+
- Digital PDF path: upload, extract, preview, unlock, analyze, and download DOCX successfully.
|
| 53 |
+
- Scanned PDF path: OCR fallback triggers automatically and still returns deadline, mandatory docs, and cited evidence.
|
| 54 |
+
- Hard-blocker path: missing bid bond, AGPO mismatch, or invalid registration produces deterministic fail states even if the LLM output is uncertain.
|
| 55 |
+
- Rate-limit path: primary model 429/timeout retries cleanly and falls back without duplicate jobs or duplicate charges.
|
| 56 |
+
- Idempotency path: repeated upload, process, unlock, or webhook calls do not create duplicate tenders, payments, or drafts.
|
| 57 |
+
- Security path: one organization cannot read another organization’s files, drafts, or analysis records.
|
| 58 |
+
- Quality path: generated text always cites extracted evidence and never invents unsupported facts.
|
| 59 |
+
- Performance targets: preview in under 60 seconds for normal digital PDFs, full analysis in 2 to 5 minutes for typical 30 to 80 page tenders, OCR-heavy cases in under 10 minutes with visible progress and manual-review fallback.
|
| 60 |
+
|
| 61 |
+
## Assumptions
|
| 62 |
+
- Kenya is the only launch market and most tender documents are English-language PDFs.
|
| 63 |
+
- v1 is upload-first. Tender scraping is intentionally out of the critical path because it adds fragility without proving willingness to pay.
|
| 64 |
+
- Supabase Cloud is acceptable even with an open-source bias because it dramatically shortens time to market while preserving open-source primitives underneath.
|
| 65 |
+
- White-label is a phase-2 revenue expansion, not a launch requirement.
|
| 66 |
+
- The first business milestone is paid validation, not perfect autonomy. During beta, low-confidence jobs can route to a manual review queue.
|
WORKERS_ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TenderHub Workers Architecture
|
| 2 |
+
|
| 3 |
+
This document describes the technical architecture and implementation details of both workers in the TenderHub platform: the primary worker and the WebAI verification worker.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
TenderHub employs a dual-worker architecture for tender document processing:
|
| 8 |
+
|
| 9 |
+
1. **Primary Worker**: Text-based extraction and analysis using traditional OCR + LLM pipeline
|
| 10 |
+
2. **WebAI Verification Worker**: Multimodal vision-language model for cross-validation
|
| 11 |
+
|
| 12 |
+
This approach provides redundancy, quality assurance, and competitive advantage through multiple AI analysis perspectives.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Primary Worker Architecture
|
| 17 |
+
|
| 18 |
+
### Purpose
|
| 19 |
+
The primary worker handles the main document processing pipeline for tender analysis and proposal generation.
|
| 20 |
+
|
| 21 |
+
### Technology Stack
|
| 22 |
+
- **Language**: Python 3.11
|
| 23 |
+
- **Database**: PostgreSQL with psycopg
|
| 24 |
+
- **Storage**: Supabase for document storage
|
| 25 |
+
- **OCR**: Stirling-PDF (optional, for image-only PDFs)
|
| 26 |
+
- **Text Extraction**: Kreuzberg with marker LLM mode
|
| 27 |
+
- **AI Analysis**: Google Gemini with caching
|
| 28 |
+
- **Deployment**: Docker/Local execution
|
| 29 |
+
|
| 30 |
+
### Processing Pipeline
|
| 31 |
+
|
| 32 |
+
#### 1. Document Ingestion
|
| 33 |
+
```python
|
| 34 |
+
# Downloads documents from Supabase storage
|
| 35 |
+
document_bytes = download_storage_object(config, storage_path)
|
| 36 |
+
mime_type = infer_mime_type(storage_path)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
#### 2. PDF Signal Analysis
|
| 40 |
+
```python
|
| 41 |
+
# Analyzes PDF characteristics
|
| 42 |
+
signal = analyze_pdf_signal(pdf_bytes)
|
| 43 |
+
page_count = signal["page_count"]
|
| 44 |
+
is_image_only = signal["is_image_only"]
|
| 45 |
+
avg_chars_per_page = signal["average_chars_per_page"]
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
#### 3. OCR Processing (if needed)
|
| 49 |
+
For image-only PDFs:
|
| 50 |
+
```python
|
| 51 |
+
stirling = StirlingClient(config)
|
| 52 |
+
ocr_bytes = stirling.ocr_pdf(source_bytes)
|
| 53 |
+
compressed = stirling.compress_pdf(ocr_bytes)
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
#### 4. Text Extraction
|
| 57 |
+
```python
|
| 58 |
+
# Batch processing with Kreuzberg
|
| 59 |
+
batches = split_pdf_batches(pdf_bytes, config.kreuzberg_batch_pages)
|
| 60 |
+
for batch in batches:
|
| 61 |
+
markdown, tables, elements = extract_with_kreuzberg(
|
| 62 |
+
batch["pdf_bytes"], mime_type, config.marker_llm_mode
|
| 63 |
+
)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
#### 5. AI Analysis with Gemini
|
| 67 |
+
```python
|
| 68 |
+
# Creates cache for efficient processing
|
| 69 |
+
cache = gemini.create_cache(
|
| 70 |
+
display_name=f"tender-{tender_id[:8]}-{doc_hash[:8]}",
|
| 71 |
+
content=markdown_text,
|
| 72 |
+
ttl_hours=config.gemini_cache_ttl_hours,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Generates structured analysis
|
| 76 |
+
analysis = gemini.generate_json_with_cache(cache_name=cache_name, prompt=prompt)
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
#### 6. Database Storage
|
| 80 |
+
```python
|
| 81 |
+
# Stores structured results
|
| 82 |
+
upsert_analysis(conn, tender_id, organization_id, analysis)
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### Key Features
|
| 86 |
+
|
| 87 |
+
#### Memory Optimization
|
| 88 |
+
- **4-bit/8-bit Quantization**: Reduces model memory footprint
|
| 89 |
+
- **Batch Processing**: Processes documents in configurable batch sizes
|
| 90 |
+
- **GC Flush**: Optional garbage collection between batches
|
| 91 |
+
- **Marker LLM Mode**: Enhanced text extraction quality
|
| 92 |
+
|
| 93 |
+
#### Caching Strategy
|
| 94 |
+
- **Gemini Cache**: 72-hour TTL for document content
|
| 95 |
+
- **Cache Validation**: Hash-based cache invalidation
|
| 96 |
+
- **Reuse Logic**: Automatic cache reuse for identical documents
|
| 97 |
+
|
| 98 |
+
#### Error Handling
|
| 99 |
+
- **Retry Logic**: Configurable retry delays and max attempts
|
| 100 |
+
- **Fallback Mechanisms**: Graceful degradation on component failures
|
| 101 |
+
- **Comprehensive Logging**: Structured JSON logging for monitoring
|
| 102 |
+
|
| 103 |
+
### Job Types
|
| 104 |
+
|
| 105 |
+
#### ANALYZE Jobs
|
| 106 |
+
- Extracts and analyzes tender documents
|
| 107 |
+
- Generates compliance items and bid scores
|
| 108 |
+
- Produces structured JSON output
|
| 109 |
+
|
| 110 |
+
#### DRAFT Jobs
|
| 111 |
+
- Generates proposal sections (Executive Summary, Technical Approach, etc.)
|
| 112 |
+
- Supports different tones (Formal, Persuasive, Concise)
|
| 113 |
+
- Uses cached analysis for consistency
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## WebAI Verification Worker Architecture
|
| 118 |
+
|
| 119 |
+
### Purpose
|
| 120 |
+
The WebAI verification worker provides a secondary analysis pipeline using multimodal AI to cross-validate primary worker results.
|
| 121 |
+
|
| 122 |
+
### Technology Stack
|
| 123 |
+
- **Language**: Python 3.11
|
| 124 |
+
- **Framework**: Gradio + HF Spaces
|
| 125 |
+
- **Model**: webAI-ColVec1-4b (multimodal vision-language)
|
| 126 |
+
- **GPU**: ZeroGPU for on-demand acceleration
|
| 127 |
+
- **Memory**: FlashAttention-2 + 8-bit quantization
|
| 128 |
+
- **Deployment**: Hugging Face Spaces
|
| 129 |
+
|
| 130 |
+
### Processing Pipeline
|
| 131 |
+
|
| 132 |
+
#### 1. Document Ingestion
|
| 133 |
+
```python
|
| 134 |
+
# Same storage access as primary worker
|
| 135 |
+
document_bytes = download_document(context["storage_path"])
|
| 136 |
+
mime_type = infer_mime_type(context["storage_path"])
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
#### 2. High DPI Image Conversion
|
| 140 |
+
```python
|
| 141 |
+
# Adaptive DPI based on available memory
|
| 142 |
+
optimal_dpi = get_optimal_dpi(config)
|
| 143 |
+
images = convert_from_bytes(
|
| 144 |
+
document_bytes,
|
| 145 |
+
dpi=optimal_dpi,
|
| 146 |
+
first_page=True,
|
| 147 |
+
fmt='JPEG'
|
| 148 |
+
)
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
#### 3. Vision-Language Processing
|
| 152 |
+
```python
|
| 153 |
+
# Direct multimodal understanding
|
| 154 |
+
@spaces.GPU
|
| 155 |
+
def process_with_webai(image: Image.Image, prompt: str):
|
| 156 |
+
inputs = processor(images=image, text=prompt, return_tensors="pt").to("cuda")
|
| 157 |
+
outputs = model.generate(**inputs, max_new_tokens=512)
|
| 158 |
+
response = processor.tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 159 |
+
return response
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
#### 4. Structured Analysis
|
| 163 |
+
```python
|
| 164 |
+
# Parses JSON response from multimodal model
|
| 165 |
+
webai_analysis = parse_webai_response(response_text)
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
#### 5. Cross-Validation
|
| 169 |
+
```python
|
| 170 |
+
# Compares with primary worker results
|
| 171 |
+
comparison = compare_analyses(primary_analysis, webai_analysis)
|
| 172 |
+
agreement_score = comparison["agreement_score"]
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
#### 6. Aggressive Memory Cleanup
|
| 176 |
+
```python
|
| 177 |
+
# Prevents ghost memory from vision tensors
|
| 178 |
+
def aggressive_memory_cleanup():
|
| 179 |
+
torch.cuda.empty_cache()
|
| 180 |
+
gc.collect() # Multiple generations
|
| 181 |
+
# Clear PIL caches and monitor effectiveness
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### Key Features
|
| 185 |
+
|
| 186 |
+
#### Memory Optimization
|
| 187 |
+
- **4B Model**: Smaller footprint vs 9B models
|
| 188 |
+
- **8-bit Quantization**: Balance of quality and memory efficiency
|
| 189 |
+
- **FlashAttention-2**: 40% reduction in attention memory
|
| 190 |
+
- **Aggressive Cleanup**: Prevents 4GB+ ghost memory accumulation
|
| 191 |
+
|
| 192 |
+
#### Adaptive DPI Scaling
|
| 193 |
+
```python
|
| 194 |
+
# Memory-aware DPI adjustment
|
| 195 |
+
if available_memory_gb >= 12: optimal_dpi = 300
|
| 196 |
+
elif available_memory_gb >= 8: optimal_dpi = 250
|
| 197 |
+
elif available_memory_gb >= 4: optimal_dpi = 200
|
| 198 |
+
else: optimal_dpi = 150
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
#### ZeroGPU Integration
|
| 202 |
+
- **On-demand GPU**: Dynamic allocation for processing tasks
|
| 203 |
+
- **Cost Efficiency**: Free tier with automatic scaling
|
| 204 |
+
- **Fallback**: CPU processing when GPU unavailable
|
| 205 |
+
|
| 206 |
+
### Comparison Engine
|
| 207 |
+
|
| 208 |
+
The verification worker implements sophisticated comparison logic:
|
| 209 |
+
|
| 210 |
+
#### Agreement Scoring
|
| 211 |
+
```python
|
| 212 |
+
# Weighted agreement calculation
|
| 213 |
+
agreement_factors = [
|
| 214 |
+
bid_decision_agreement * 0.4, # Bid decision (40%)
|
| 215 |
+
confidence_similarity * 0.3, # Confidence scores (30%)
|
| 216 |
+
category_similarity * 0.2, # Tender categories (20%)
|
| 217 |
+
deadline_agreement * 0.1 # Deadlines (10%)
|
| 218 |
+
]
|
| 219 |
+
agreement_score = sum(agreement_factors)
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
#### Discrepancy Detection
|
| 223 |
+
- **High Severity**: Bid decision differences
|
| 224 |
+
- **Medium Severity**: Confidence score gaps > 0.3
|
| 225 |
+
- **Low Severity**: Category or deadline mismatches
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## Database Integration
|
| 230 |
+
|
| 231 |
+
### Shared Tables
|
| 232 |
+
Both workers interact with these core tables:
|
| 233 |
+
|
| 234 |
+
#### tenders
|
| 235 |
+
```sql
|
| 236 |
+
CREATE TABLE tenders (
|
| 237 |
+
id UUID PRIMARY KEY,
|
| 238 |
+
organization_id UUID,
|
| 239 |
+
title TEXT,
|
| 240 |
+
status TEXT, -- PROCESSING, ANALYSIS_READY, FAILED
|
| 241 |
+
storage_path TEXT,
|
| 242 |
+
verification_status TEXT, -- PENDING, PROCESSING, COMPLETED
|
| 243 |
+
verification_score FLOAT
|
| 244 |
+
);
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
#### processing_jobs
|
| 248 |
+
```sql
|
| 249 |
+
CREATE TABLE processing_jobs (
|
| 250 |
+
id UUID PRIMARY KEY,
|
| 251 |
+
tender_id UUID,
|
| 252 |
+
job_type TEXT, -- ANALYZE, DRAFT, VERIFY
|
| 253 |
+
status TEXT, -- QUEUED, RUNNING, SUCCEEDED, FAILED
|
| 254 |
+
payload JSONB,
|
| 255 |
+
attempt_count INTEGER,
|
| 256 |
+
max_attempts INTEGER,
|
| 257 |
+
lock_owner TEXT,
|
| 258 |
+
available_at TIMESTAMP,
|
| 259 |
+
created_at TIMESTAMP,
|
| 260 |
+
updated_at TIMESTAMP
|
| 261 |
+
);
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
#### extractions (Primary Worker)
|
| 265 |
+
```sql
|
| 266 |
+
CREATE TABLE extractions (
|
| 267 |
+
tender_id UUID PRIMARY KEY,
|
| 268 |
+
structured_output JSONB,
|
| 269 |
+
summary TEXT,
|
| 270 |
+
updated_at TIMESTAMP
|
| 271 |
+
);
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
#### webai_verifications (Verification Worker)
|
| 275 |
+
```sql
|
| 276 |
+
CREATE TABLE webai_verifications (
|
| 277 |
+
id UUID PRIMARY KEY,
|
| 278 |
+
tender_id UUID,
|
| 279 |
+
analysis JSONB,
|
| 280 |
+
comparison JSONB,
|
| 281 |
+
created_at TIMESTAMP,
|
| 282 |
+
updated_at TIMESTAMP
|
| 283 |
+
);
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
### Job Queue System
|
| 287 |
+
|
| 288 |
+
#### Job Claiming
|
| 289 |
+
```python
|
| 290 |
+
# Atomic job claiming with row-level locking
|
| 291 |
+
cur.execute("""
|
| 292 |
+
SELECT * FROM processing_jobs
|
| 293 |
+
WHERE status = 'QUEUED' AND available_at <= now()
|
| 294 |
+
ORDER BY created_at ASC
|
| 295 |
+
FOR UPDATE SKIP LOCKED
|
| 296 |
+
LIMIT 1
|
| 297 |
+
""")
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
#### Status Management
|
| 301 |
+
- **QUEUED**: Ready for processing
|
| 302 |
+
- **RUNNING**: Being processed by a worker
|
| 303 |
+
- **SUCCEEDED**: Completed successfully
|
| 304 |
+
- **FAILED**: Failed after max retries
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
## Performance Characteristics
|
| 309 |
+
|
| 310 |
+
### Primary Worker
|
| 311 |
+
- **Throughput**: ~15-25 documents/hour (CPU optimized)
|
| 312 |
+
- **Memory Usage**: ~2-4GB per document
|
| 313 |
+
- **Accuracy**: High for text-heavy documents
|
| 314 |
+
- **Strengths**: Structured data extraction, compliance analysis
|
| 315 |
+
|
| 316 |
+
### WebAI Verification Worker
|
| 317 |
+
- **Throughput**: ~20-30 documents/hour (GPU accelerated)
|
| 318 |
+
- **Memory Usage**: ~4-8GB per document (with cleanup)
|
| 319 |
+
- **Accuracy**: Superior for visually complex documents
|
| 320 |
+
- **Strengths**: Multimodal understanding, cross-validation
|
| 321 |
+
|
| 322 |
+
### Memory Optimization Strategies
|
| 323 |
+
|
| 324 |
+
#### Primary Worker
|
| 325 |
+
1. **Quantization**: 4-bit/8-bit model loading
|
| 326 |
+
2. **Batching**: Configurable batch sizes (10-20 pages)
|
| 327 |
+
3. **GC Management**: Optional garbage collection
|
| 328 |
+
4. **Caching**: Gemini content caching
|
| 329 |
+
|
| 330 |
+
#### WebAI Worker
|
| 331 |
+
1. **Model Selection**: 4B vs 9B model choice
|
| 332 |
+
2. **FlashAttention-2**: Attention mechanism optimization
|
| 333 |
+
3. **Aggressive Cleanup**: Prevent ghost memory
|
| 334 |
+
4. **Adaptive DPI**: Memory-aware resolution scaling
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
## Deployment Architecture
|
| 339 |
+
|
| 340 |
+
### Primary Worker
|
| 341 |
+
- **Environment**: Docker containers or local execution
|
| 342 |
+
- **Scaling**: Horizontal scaling via multiple instances
|
| 343 |
+
- **Monitoring**: Structured logging + health checks
|
| 344 |
+
- **Configuration**: Environment-based configuration
|
| 345 |
+
|
| 346 |
+
### WebAI Verification Worker
|
| 347 |
+
- **Platform**: Hugging Face Spaces
|
| 348 |
+
- **GPU**: ZeroGPU on-demand allocation
|
| 349 |
+
- **Interface**: Gradio web interface + API
|
| 350 |
+
- **Cost**: Free tier with dynamic scaling
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## Cross-Validation Benefits
|
| 355 |
+
|
| 356 |
+
### Quality Assurance
|
| 357 |
+
1. **Dual Perspectives**: Text-only vs multimodal analysis
|
| 358 |
+
2. **Discrepancy Detection**: Automatic identification of differences
|
| 359 |
+
3. **Confidence Scoring**: Agreement metrics for reliability assessment
|
| 360 |
+
4. **Human Review**: Flagged items for manual verification
|
| 361 |
+
|
| 362 |
+
### Competitive Advantages
|
| 363 |
+
1. **Accuracy**: Multiple AI approaches reduce error rates
|
| 364 |
+
2. **Reliability**: Cross-validation prevents single-model failures
|
| 365 |
+
3. **Insights**: Different models catch different issues
|
| 366 |
+
4. **Trust**: Transparent verification builds user confidence
|
| 367 |
+
|
| 368 |
+
### Use Cases
|
| 369 |
+
- **High-Value Tenders**: Critical bids requiring maximum accuracy
|
| 370 |
+
- **Complex Documents**: Visually dense or poorly scanned PDFs
|
| 371 |
+
- **Regulatory Compliance**: Industries requiring verification
|
| 372 |
+
- **Quality Control**: Organizations with zero-tolerance for errors
|
| 373 |
+
|
| 374 |
+
---
|
| 375 |
+
|
| 376 |
+
## Monitoring and Observability
|
| 377 |
+
|
| 378 |
+
### Logging Strategy
|
| 379 |
+
Both workers use structured JSON logging:
|
| 380 |
+
```json
|
| 381 |
+
{
|
| 382 |
+
"timestamp": "2024-01-01T12:00:00Z",
|
| 383 |
+
"event": "job.completed",
|
| 384 |
+
"service": "primary-worker",
|
| 385 |
+
"tender_id": "uuid",
|
| 386 |
+
"job_type": "ANALYZE",
|
| 387 |
+
"processing_time_ms": 15000,
|
| 388 |
+
"memory_peak_gb": 3.2
|
| 389 |
+
}
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
### Key Metrics
|
| 393 |
+
- **Throughput**: Documents processed per hour
|
| 394 |
+
- **Latency**: Processing time per document
|
| 395 |
+
- **Memory**: Peak memory usage
|
| 396 |
+
- **Success Rate**: Job completion percentage
|
| 397 |
+
- **Agreement Score**: Cross-validation accuracy
|
| 398 |
+
|
| 399 |
+
### Health Checks
|
| 400 |
+
- **Database Connectivity**: Connection pool status
|
| 401 |
+
- **Storage Access**: Supabase connectivity
|
| 402 |
+
- **Model Availability**: AI service health
|
| 403 |
+
- **Memory Usage**: System resource monitoring
|
| 404 |
+
|
| 405 |
+
---
|
| 406 |
+
|
| 407 |
+
## Future Enhancements
|
| 408 |
+
|
| 409 |
+
### Primary Worker
|
| 410 |
+
- **Advanced OCR**: Integration with additional OCR services
|
| 411 |
+
- **Multi-language**: Support for non-English documents
|
| 412 |
+
- **Parallel Processing**: Multi-threaded document processing
|
| 413 |
+
- **Enhanced Caching**: Redis-based caching layer
|
| 414 |
+
|
| 415 |
+
### WebAI Verification Worker
|
| 416 |
+
- **Model Upgrades**: Latest multimodal models
|
| 417 |
+
- **Batch Processing**: Multiple document processing
|
| 418 |
+
- **Custom Training**: Fine-tuned models for tender documents
|
| 419 |
+
- **Real-time Processing**: Streaming document analysis
|
| 420 |
+
|
| 421 |
+
### Integration Improvements
|
| 422 |
+
- **Unified API**: Single endpoint for both workers
|
| 423 |
+
- **Load Balancing**: Intelligent job routing
|
| 424 |
+
- **Failover**: Automatic worker switching
|
| 425 |
+
- **Metrics Dashboard**: Real-time monitoring interface
|
| 426 |
+
|
| 427 |
+
---
|
| 428 |
+
|
| 429 |
+
## Conclusion
|
| 430 |
+
|
| 431 |
+
The dual-worker architecture provides TenderHub with a robust, scalable, and accurate document processing system. By combining traditional text-based extraction with modern multimodal AI, the platform achieves:
|
| 432 |
+
|
| 433 |
+
- **Higher Accuracy**: Cross-validation reduces errors
|
| 434 |
+
- **Better Reliability**: Multiple processing paths prevent failures
|
| 435 |
+
- **Competitive Advantage**: Superior analysis quality
|
| 436 |
+
- **Future-Proof**: Extensible architecture for new AI models
|
| 437 |
+
|
| 438 |
+
This architecture positions TenderHub as a leader in AI-powered tender analysis, providing customers with the confidence and accuracy needed for critical business decisions.
|
apps/web/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.vercel
|
| 2 |
+
.env*.local
|
apps/web/e2e/basic.spec.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test';
|
| 2 |
+
|
| 3 |
+
test.describe('TenderHub Core Flows', () => {
|
| 4 |
+
test('landing page loads and has expected elements', async ({ page }) => {
|
| 5 |
+
await page.goto('/');
|
| 6 |
+
|
| 7 |
+
// Check for standard landing page elements.
|
| 8 |
+
// If the landing page changes, update these assertions.
|
| 9 |
+
await expect(page).toHaveTitle(/TenderHub/i);
|
| 10 |
+
|
| 11 |
+
// Check if the dashboard console is still reachable (for dev check)
|
| 12 |
+
await page.goto('/console');
|
| 13 |
+
await expect(page.locator('body')).toBeVisible();
|
| 14 |
+
});
|
| 15 |
+
});
|
apps/web/eslint.config.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 2 |
+
|
| 3 |
+
const config = [...nextVitals];
|
| 4 |
+
|
| 5 |
+
export default config;
|
apps/web/jest.config.cjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
| 2 |
+
module.exports = {
|
| 3 |
+
preset: 'ts-jest',
|
| 4 |
+
testEnvironment: 'jsdom',
|
| 5 |
+
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
| 6 |
+
testPathIgnorePatterns: [
|
| 7 |
+
'<rootDir>/node_modules/',
|
| 8 |
+
'<rootDir>/.next/',
|
| 9 |
+
],
|
| 10 |
+
moduleNameMapper: {
|
| 11 |
+
'^@/(.*)$': '<rootDir>/src/$1',
|
| 12 |
+
'^@tenderhub/schemas$': '<rootDir>/../../packages/schemas/src/index.ts',
|
| 13 |
+
},
|
| 14 |
+
transform: {
|
| 15 |
+
'^.+\\.tsx?$': [
|
| 16 |
+
'ts-jest',
|
| 17 |
+
{
|
| 18 |
+
tsconfig: {
|
| 19 |
+
jsx: 'react-jsx',
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
],
|
| 23 |
+
},
|
| 24 |
+
testMatch: [
|
| 25 |
+
'**/__tests__/**/*.test.ts',
|
| 26 |
+
'**/__tests__/**/*.test.tsx',
|
| 27 |
+
],
|
| 28 |
+
};
|
apps/web/jest.setup.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Jest Setup
|
| 3 |
+
* Configure test environment
|
| 4 |
+
*/
|
| 5 |
+
require('@testing-library/jest-dom')
|
| 6 |
+
|
| 7 |
+
// Mock Next.js headers
|
| 8 |
+
jest.mock('next/headers', () => ({
|
| 9 |
+
cookies: jest.fn(() => ({
|
| 10 |
+
get: jest.fn(),
|
| 11 |
+
set: jest.fn(),
|
| 12 |
+
})),
|
| 13 |
+
headers: jest.fn(() => new Headers()),
|
| 14 |
+
}))
|
| 15 |
+
|
| 16 |
+
// Mock environment variables
|
| 17 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
| 18 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'
|
| 19 |
+
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'
|
| 20 |
+
process.env.LITELLM_BASE_URL = 'http://localhost:4000'
|
| 21 |
+
process.env.LITELLM_API_KEY = 'test-litellm-key'
|
| 22 |
+
|
| 23 |
+
// Global test utilities
|
| 24 |
+
global.mockFetch = (response) => {
|
| 25 |
+
global.fetch = jest.fn(() =>
|
| 26 |
+
Promise.resolve({
|
| 27 |
+
ok: true,
|
| 28 |
+
json: () => Promise.resolve(response),
|
| 29 |
+
text: () => Promise.resolve(JSON.stringify(response)),
|
| 30 |
+
})
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Clean up after each test
|
| 35 |
+
afterEach(() => {
|
| 36 |
+
jest.clearAllMocks()
|
| 37 |
+
})
|
apps/web/netlify.toml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build]
|
| 2 |
+
command = "npm run build"
|
| 3 |
+
publish = ".next"
|
| 4 |
+
|
| 5 |
+
[[plugins]]
|
| 6 |
+
package = "@netlify/plugin-nextjs"
|
apps/web/next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/dev/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
apps/web/next.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
import withPWAInit from "@ducanh2912/next-pwa";
|
| 3 |
+
|
| 4 |
+
const withPWA = withPWAInit({
|
| 5 |
+
dest: "public",
|
| 6 |
+
disable: process.env.NODE_ENV === "development",
|
| 7 |
+
register: true,
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const nextConfig: NextConfig = {
|
| 11 |
+
transpilePackages: ["@tenderhub/schemas"]
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export default withPWA(nextConfig);
|
apps/web/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@tenderhub/web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev --webpack",
|
| 8 |
+
"build": "next build --webpack",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "eslint . --max-warnings=0",
|
| 11 |
+
"typecheck": "tsc --noEmit",
|
| 12 |
+
"test": "jest"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"@ducanh2912/next-pwa": "^10.2.9",
|
| 16 |
+
"@hookform/resolvers": "^5.2.2",
|
| 17 |
+
"@radix-ui/react-accordion": "^1.2.12",
|
| 18 |
+
"@radix-ui/react-alert-dialog": "^1.1.15",
|
| 19 |
+
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
| 20 |
+
"@radix-ui/react-avatar": "^1.1.11",
|
| 21 |
+
"@radix-ui/react-checkbox": "^1.3.3",
|
| 22 |
+
"@radix-ui/react-collapsible": "^1.1.12",
|
| 23 |
+
"@radix-ui/react-context-menu": "^2.2.16",
|
| 24 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 25 |
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 26 |
+
"@radix-ui/react-hover-card": "^1.1.15",
|
| 27 |
+
"@radix-ui/react-label": "^2.1.8",
|
| 28 |
+
"@radix-ui/react-menubar": "^1.1.16",
|
| 29 |
+
"@radix-ui/react-navigation-menu": "^1.2.14",
|
| 30 |
+
"@radix-ui/react-popover": "^1.1.15",
|
| 31 |
+
"@radix-ui/react-progress": "^1.1.8",
|
| 32 |
+
"@radix-ui/react-radio-group": "^1.3.8",
|
| 33 |
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
| 34 |
+
"@radix-ui/react-select": "^2.2.6",
|
| 35 |
+
"@radix-ui/react-separator": "^1.1.8",
|
| 36 |
+
"@radix-ui/react-slider": "^1.3.6",
|
| 37 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 38 |
+
"@radix-ui/react-switch": "^1.2.6",
|
| 39 |
+
"@radix-ui/react-tabs": "^1.1.13",
|
| 40 |
+
"@radix-ui/react-toast": "^1.2.15",
|
| 41 |
+
"@radix-ui/react-toggle": "^1.1.10",
|
| 42 |
+
"@radix-ui/react-toggle-group": "^1.1.11",
|
| 43 |
+
"@radix-ui/react-tooltip": "^1.2.8",
|
| 44 |
+
"@sentry/nextjs": "^10.48.0",
|
| 45 |
+
"@supabase/ssr": "^0.10.2",
|
| 46 |
+
"@supabase/supabase-js": "^2.49.4",
|
| 47 |
+
"@tailwindcss/postcss": "^4.2.2",
|
| 48 |
+
"@tanstack/react-query": "^5.99.0",
|
| 49 |
+
"@tenderhub/schemas": "0.1.0",
|
| 50 |
+
"class-variance-authority": "^0.7.1",
|
| 51 |
+
"clsx": "^2.1.1",
|
| 52 |
+
"cmdk": "^1.1.1",
|
| 53 |
+
"date-fns": "^4.1.0",
|
| 54 |
+
"docx": "^9.6.1",
|
| 55 |
+
"embla-carousel-react": "^8.6.0",
|
| 56 |
+
"input-otp": "^1.4.2",
|
| 57 |
+
"lucide-react": "^1.8.0",
|
| 58 |
+
"next": "^16.2.2",
|
| 59 |
+
"next-themes": "^0.4.6",
|
| 60 |
+
"posthog-js": "^1.367.0",
|
| 61 |
+
"react": "^19.2.0",
|
| 62 |
+
"react-day-picker": "^9.14.0",
|
| 63 |
+
"react-dom": "^19.2.0",
|
| 64 |
+
"react-hook-form": "^7.72.1",
|
| 65 |
+
"react-resizable-panels": "^4.10.0",
|
| 66 |
+
"recharts": "^3.8.1",
|
| 67 |
+
"sonner": "^2.0.7",
|
| 68 |
+
"tailwind-merge": "^3.5.0",
|
| 69 |
+
"tailwindcss-animate": "^1.0.7",
|
| 70 |
+
"vaul": "^1.1.2",
|
| 71 |
+
"zod": "^3.24.4"
|
| 72 |
+
},
|
| 73 |
+
"devDependencies": {
|
| 74 |
+
"@playwright/test": "^1.49.1",
|
| 75 |
+
"@testing-library/jest-dom": "^6.9.1",
|
| 76 |
+
"@testing-library/react": "^16.3.2",
|
| 77 |
+
"@types/jest": "^30.0.0",
|
| 78 |
+
"@types/node": "^22.19.17",
|
| 79 |
+
"@types/react": "^19.1.2",
|
| 80 |
+
"@types/react-dom": "^19.1.2",
|
| 81 |
+
"autoprefixer": "^10.5.0",
|
| 82 |
+
"eslint": "^9.25.0",
|
| 83 |
+
"eslint-config-next": "^16.2.2",
|
| 84 |
+
"jest": "^30.3.0",
|
| 85 |
+
"jest-environment-jsdom": "^30.3.0",
|
| 86 |
+
"node-mocks-http": "^1.17.2",
|
| 87 |
+
"postcss": "^8.4.31",
|
| 88 |
+
"tailwindcss": "^4.2.2",
|
| 89 |
+
"ts-jest": "^29.4.9",
|
| 90 |
+
"typescript": "^5.8.3"
|
| 91 |
+
}
|
| 92 |
+
}
|
apps/web/playwright.config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from '@playwright/test';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* See https://playwright.dev/docs/test-configuration.
|
| 5 |
+
*/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
testDir: './e2e',
|
| 8 |
+
fullyParallel: true,
|
| 9 |
+
forbidOnly: !!process.env.CI,
|
| 10 |
+
retries: process.env.CI ? 2 : 0,
|
| 11 |
+
workers: process.env.CI ? 1 : undefined,
|
| 12 |
+
reporter: 'html',
|
| 13 |
+
use: {
|
| 14 |
+
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
| 15 |
+
trace: 'on-first-retry',
|
| 16 |
+
},
|
| 17 |
+
projects: [
|
| 18 |
+
{
|
| 19 |
+
name: 'chromium',
|
| 20 |
+
use: { ...devices['Desktop Chrome'] },
|
| 21 |
+
},
|
| 22 |
+
],
|
| 23 |
+
webServer: {
|
| 24 |
+
command: 'npm run dev',
|
| 25 |
+
url: 'http://localhost:3000',
|
| 26 |
+
reuseExistingServer: !process.env.CI,
|
| 27 |
+
},
|
| 28 |
+
});
|
apps/web/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
apps/web/public/icons/icon-192.png
ADDED
|
|
apps/web/public/icons/icon-512.png
ADDED
|
|
apps/web/public/manifest.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "TenderHub Kenya",
|
| 3 |
+
"short_name": "TenderHub",
|
| 4 |
+
"description": "AI-powered tender execution platform for Kenyan SMEs and consultants",
|
| 5 |
+
"start_url": "/dashboard",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#f8fafb",
|
| 8 |
+
"theme_color": "#059669",
|
| 9 |
+
"orientation": "portrait-primary",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "/icons/icon-192.png",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/png",
|
| 15 |
+
"purpose": "any maskable"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"src": "/icons/icon-512.png",
|
| 19 |
+
"sizes": "512x512",
|
| 20 |
+
"type": "image/png",
|
| 21 |
+
"purpose": "any maskable"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"categories": ["business", "productivity"],
|
| 25 |
+
"lang": "en",
|
| 26 |
+
"dir": "ltr"
|
| 27 |
+
}
|
apps/web/public/sw.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
if(!self.define){let e,s={};const t=(t,n)=>(t=new URL(t+".js",n).href,s[t]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=t,e.onload=s,document.head.appendChild(e)}else e=t,importScripts(t),s()}).then(()=>{let e=s[t];if(!e)throw new Error(`Module ${t} didn’t register its module`);return e}));self.define=(n,a)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const d=e=>t(e,i),r={module:{uri:i},exports:c,require:d};s[i]=Promise.all(n.map(e=>r[e]||d(e))).then(e=>(a(...e),c))}}define(["./workbox-b52a85cb"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/L4hQgc15WqfIHZwcLSX3G/_buildManifest.js",revision:"1f8cdb6d84803b62bc3866b6c120a3d8"},{url:"/_next/static/L4hQgc15WqfIHZwcLSX3G/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1446.2a26417c759e67b8.js",revision:"2a26417c759e67b8"},{url:"/_next/static/chunks/445-80c5b3bdef721e07.js",revision:"80c5b3bdef721e07"},{url:"/_next/static/chunks/6387.9a7bf416cbcd193d.js",revision:"9a7bf416cbcd193d"},{url:"/_next/static/chunks/7453-5362801bf71a3662.js",revision:"5362801bf71a3662"},{url:"/_next/static/chunks/75504863-ea0a4eadd7cf0a00.js",revision:"ea0a4eadd7cf0a00"},{url:"/_next/static/chunks/87c73c54-f46fec743414da25.js",revision:"f46fec743414da25"},{url:"/_next/static/chunks/9348.63f2aee1edfebcb6.js",revision:"63f2aee1edfebcb6"},{url:"/_next/static/chunks/app/_global-error/page-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/_not-found/page-9606f522f24bf594.js",revision:"9606f522f24bf594"},{url:"/_next/static/chunks/app/admin/layout-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/admin/page-7814eb33274edf87.js",revision:"7814eb33274edf87"},{url:"/_next/static/chunks/app/api/admin/activity/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/check/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/jobs/retry/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/jobs/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/stats/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/users/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/admin/users/toggle-superuser/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/company-profile/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/callback/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/status/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/payments/mpesa/stk/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/analysis/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/batch/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/export/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/drafts/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/events/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/jobs/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/outcome/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/preview/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/process/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/review-action/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/status/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/%5Bid%5D/unlock/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/review-queue/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/tenders/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/api/uploads/init/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/auth/callback/route-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/console/page-0fbc7f6f1669ba32.js",revision:"0fbc7f6f1669ba32"},{url:"/_next/static/chunks/app/dashboard/layout-1b9b9baa668911be.js",revision:"1b9b9baa668911be"},{url:"/_next/static/chunks/app/dashboard/page-784500552c688434.js",revision:"784500552c688434"},{url:"/_next/static/chunks/app/dashboard/profile/page-416e752a44c50a5d.js",revision:"416e752a44c50a5d"},{url:"/_next/static/chunks/app/dashboard/tenders/%5Bid%5D/page-9ab4af1e3c7459b1.js",revision:"9ab4af1e3c7459b1"},{url:"/_next/static/chunks/app/dashboard/upload/page-b6a0282b19a09a9b.js",revision:"b6a0282b19a09a9b"},{url:"/_next/static/chunks/app/layout-b8eea8c8fef7c339.js",revision:"b8eea8c8fef7c339"},{url:"/_next/static/chunks/app/login/page-a70d1683c9c64722.js",revision:"a70d1683c9c64722"},{url:"/_next/static/chunks/app/page-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/app/signup/page-3a7db5ae5b62c86f.js",revision:"3a7db5ae5b62c86f"},{url:"/_next/static/chunks/framework-af3c63e39f557570.js",revision:"af3c63e39f557570"},{url:"/_next/static/chunks/main-app-6f1d7875af57e840.js",revision:"6f1d7875af57e840"},{url:"/_next/static/chunks/main-d6e97713b784943a.js",revision:"d6e97713b784943a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-99b983b19fb84353.js",revision:"99b983b19fb84353"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-744358325e4fdd6e.js",revision:"744358325e4fdd6e"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7854310dc8f9cd55.js",revision:"7854310dc8f9cd55"},{url:"/_next/static/css/0ac91d5f0f908c34.css",revision:"0ac91d5f0f908c34"},{url:"/icons/icon-192.png",revision:"2a637d3d825673c0e3462fa4ed9a1c5c"},{url:"/icons/icon-512.png",revision:"2a637d3d825673c0e3462fa4ed9a1c5c"},{url:"/manifest.json",revision:"5c60071d592bb2d645b2268625f4b573"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/_next\/static.+\.js$/i,new e.CacheFirst({cacheName:"next-static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4|webm)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:48,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e,url:{pathname:s}})=>!(!e||s.startsWith("/api/auth/callback")||!s.startsWith("/api/")),new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:t})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&t&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc-prefetch",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:t})=>"1"===e.headers.get("RSC")&&t&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:{pathname:e},sameOrigin:s})=>s&&!e.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e})=>!e,new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
|
apps/web/public/workbox-b52a85cb.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
define(["exports"],function(t){"use strict";try{self["workbox:core:7.0.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.0.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:7.0.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:r,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,e){const n=m(t);var r;await(r=0,new Promise(t=>setTimeout(t,r)));const i=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=i.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.R(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const r=p(e.url,s);if(e.url===r)return t.match(e,n);const i=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,i);for(const e of a)if(r===p(e.url,s))return t.match(e,n)}(u,i.clone(),["__WB_REVISION__"],h):null;try{await u.put(i,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of g)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:i,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const r=Object.assign(Object.assign({},n),{state:s});return e[t](r)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async R(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class R{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,r=new v(this,{event:e,request:s,params:n}),i=this.q(r,s,e);return[i,this.D(i,r,s,e)]}async q(t,e,n){let r;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(r=await this.U(e,t),!r||"error"===r.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(r=await i({error:s,event:n,request:e}),r)break;if(!r)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))r=await s({event:n,request:e,response:r});return r}async D(t,e,s,n){let r,i;try{r=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:i}),e.destroy(),i)throw i}}function b(t){t.then(()=>{})}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var s=arguments[e];for(var n in s)({}).hasOwnProperty.call(s,n)&&(t[n]=s[n])}return t},q.apply(null,arguments)}let D,U;const x=new WeakMap,L=new WeakMap,I=new WeakMap,C=new WeakMap,E=new WeakMap;let N={get(t,e,s){if(t instanceof IDBTransaction){if("done"===e)return L.get(t);if("objectStoreNames"===e)return t.objectStoreNames||I.get(t);if("store"===e)return s.objectStoreNames[1]?void 0:s.objectStore(s.objectStoreNames[0])}return k(t[e])},set:(t,e,s)=>(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(B(this),e),k(x.get(this))}:function(...e){return k(t.apply(B(this),e))}:function(e,...s){const n=t.call(B(this),e,...s);return I.set(n,e.sort?e.sort():[e]),k(n)}}function T(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(L.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)});L.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,N):t);var e}function k(t){if(t instanceof IDBRequest)return function(t){const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("success",r),t.removeEventListener("error",i)},r=()=>{e(k(t.result)),n()},i=()=>{s(t.error),n()};t.addEventListener("success",r),t.addEventListener("error",i)});return e.then(e=>{e instanceof IDBCursor&&x.set(e,t)}).catch(()=>{}),E.set(e,t),e}(t);if(C.has(t))return C.get(t);const e=T(t);return e!==t&&(C.set(t,e),E.set(e,t)),e}const B=t=>E.get(t);const P=["get","getKey","getAll","getAllKeys","count"],M=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,r=M.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!r&&!P.includes(s))return;const i=async function(t,...e){const i=this.transaction(t,r?"readwrite":"readonly");let a=i.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),r&&i.done]))[0]};return W.set(e,i),i}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:7.0.0"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.L=t}I(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.I(t),this.L&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",t=>e(t.oldVersion,t)),k(s).then(()=>{})}(this.L)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.L,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const r=[];let i=0;for(;n;){const s=n.value;s.cacheName===this.L&&(t&&s.timestamp<t||e&&i>=e?r.push(n.value):i++),n=await n.continue()}const a=[];for(const t of r)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.L+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:r,terminated:i}={}){const a=indexedDB.open(t,e),o=k(a);return n&&a.addEventListener("upgradeneeded",t=>{n(k(a.result),t.oldVersion,t.newVersion,k(a.transaction),t)}),s&&a.addEventListener("blocked",t=>s(t.oldVersion,t.newVersion,t)),o.then(t=>{i&&t.addEventListener("close",()=>i()),r&&t.addEventListener("versionchange",t=>r(t.oldVersion,t.newVersion,t))}).catch(()=>{}),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.T=!1,this.k=e.maxEntries,this.B=e.maxAgeSeconds,this.P=e.matchOptions,this.L=t,this.M=new A(t)}async expireEntries(){if(this.O)return void(this.T=!0);this.O=!0;const t=this.B?Date.now()-1e3*this.B:0,e=await this.M.expireEntries(t,this.k),s=await self.caches.open(this.L);for(const t of e)await s.delete(t,this.P);this.O=!1,this.T&&(this.T=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.M.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.B){const e=await this.M.getTimestamp(t),s=Date.now()-1e3*this.B;return void 0===e||e<s}return!1}async delete(){this.T=!1,await this.M.expireEntries(1/0)}}try{self["workbox:range-requests:7.0.0"]&&_()}catch(t){}async function H(t,e){try{if(206===e.status)return e;const n=t.headers.get("range");if(!n)throw new s("no-range-header");const r=function(t){const e=t.trim().toLowerCase();if(!e.startsWith("bytes="))throw new s("unit-must-be-bytes",{normalizedRangeHeader:e});if(e.includes(","))throw new s("single-range-only",{normalizedRangeHeader:e});const n=/(\d*)-(\d*)/.exec(e);if(!n||!n[1]&&!n[2])throw new s("invalid-range-values",{normalizedRangeHeader:e});return{start:""===n[1]?void 0:Number(n[1]),end:""===n[2]?void 0:Number(n[2])}}(n),i=await e.blob(),a=function(t,e,n){const r=t.size;if(n&&n>r||e&&e<0)throw new s("range-not-satisfiable",{size:r,end:n,start:e});let i,a;return void 0!==e&&void 0!==n?(i=e,a=n+1):void 0!==e&&void 0===n?(i=e,a=r):void 0!==n&&void 0===e&&(i=r-n,a=r),{start:i,end:a}}(i,r.start,r.end),o=i.slice(a.start,a.end),c=o.size,h=new Response(o,{status:206,statusText:"Partial Content",headers:e.headers});return h.headers.set("Content-Length",String(c)),h.headers.set("Content-Range",`bytes ${a.start}-${a.end-1}/${i.size}`),h}catch(t){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}function $(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.0.0"]&&_()}catch(t){}function z(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const r=new URL(n,location.href),i=new URL(n,location.href);return r.searchParams.set("__WB_REVISION__",e),{cacheKey:r.href,url:i.href}}class G{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function X(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const r=t.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},a=e?e(i):i,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?r.body:await r.blob();return new Response(o,a)}class Y extends R{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(Y.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const r=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=r.integrity,i=t.integrity,a=!i||i===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?i||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==Y.copyRedirectedCacheableResponsesPlugin&&(n===Y.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(Y.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}Y.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},Y.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await X(t):t};class Z{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new Y({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:r}=z(n),i="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(r)&&this.F.get(r)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(r),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:r});this.$.set(t,n.integrity)}if(this.F.set(r,t),this.H.set(r,i),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return $(t,async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),r=this.H.get(e),i=new Request(e,{integrity:n,cache:r,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:i,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return $(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const r of e)s.has(r.url)||(await t.delete(r),n.push(r.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const tt=()=>(Q||(Q=new Z),Q);class et extends r{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const r of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:r}={}){const i=new URL(t,location.href);i.hash="",yield i.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(i,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(r){const t=r({url:i});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(r);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.CacheFirst=class extends R{async U(t,e){let n,r=await e.cacheMatch(t);if(!r)try{r=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new s("no-response",{url:t.url,error:n});return r}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const r=this.V(n),i=this.J(s);b(i.expireEntries());const a=i.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return r?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.B=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){g.add(t)}(()=>this.deleteCacheAndMetadata())}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.B)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.B}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],r=[];let i;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});i=s,r.push(a)}const a=this.st({timeoutId:i,request:t,logs:n,handler:e});r.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(r))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise(e=>{n=setTimeout(async()=>{e(await s.cacheMatch(t))},1e3*this.tt)}),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let r,i;try{i=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(r=t)}return t&&clearTimeout(t),!r&&i||(i=await n.cacheMatch(e)),i}},t.RangeRequestsPlugin=class{constructor(){this.cachedResponseWillBeUsed=async({request:t,cachedResponse:e})=>e&&t.headers.has("range")?await H(t,e):e}},t.StaleWhileRevalidate=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch(()=>{});e.waitUntil(n);let r,i=await e.cacheMatch(t);if(i);else try{i=await n}catch(t){t instanceof Error&&(r=t)}if(!i)throw new s("no-response",{url:t.url,error:r});return i}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){tt().precache(t)}(t),function(t){const e=tt();h(new et(e,t))}(e)},t.registerRoute=h});
|
apps/web/scratch/check_user.cjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') });
|
| 6 |
+
|
| 7 |
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
| 8 |
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
| 9 |
+
|
| 10 |
+
if (!supabaseUrl || !supabaseServiceKey) {
|
| 11 |
+
console.error('Missing Supabase credentials');
|
| 12 |
+
process.exit(1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
| 16 |
+
|
| 17 |
+
async function checkUser() {
|
| 18 |
+
const { data: { users }, error } = await supabase.auth.admin.listUsers();
|
| 19 |
+
if (error) {
|
| 20 |
+
console.error('Error listing users:', error);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
const tester = users.find(u => u.email === 'tester@tenderhub.co.ke');
|
| 24 |
+
if (tester) {
|
| 25 |
+
console.log('User exists:', tester.id);
|
| 26 |
+
} else {
|
| 27 |
+
console.log('User does not exist');
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
checkUser();
|
apps/web/scratch/delete_user.cjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') });
|
| 6 |
+
|
| 7 |
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
| 8 |
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
| 9 |
+
|
| 10 |
+
if (!supabaseUrl || !supabaseServiceKey) {
|
| 11 |
+
console.error('Missing Supabase credentials');
|
| 12 |
+
process.exit(1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
| 16 |
+
|
| 17 |
+
async function deleteUser() {
|
| 18 |
+
const { data: { users }, error: listError } = await supabase.auth.admin.listUsers();
|
| 19 |
+
if (listError) {
|
| 20 |
+
console.error('Error listing users:', listError);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
const tester = users.find(u => u.email === 'tester@tenderhub.co.ke');
|
| 24 |
+
if (tester) {
|
| 25 |
+
console.log('User found:', tester.id, '. Deleting...');
|
| 26 |
+
const { error: deleteError } = await supabase.auth.admin.deleteUser(tester.id);
|
| 27 |
+
if (deleteError) {
|
| 28 |
+
console.error('Error deleting user:', deleteError);
|
| 29 |
+
} else {
|
| 30 |
+
console.log('User deleted successfully.');
|
| 31 |
+
}
|
| 32 |
+
} else {
|
| 33 |
+
console.log('User not found, nothing to delete.');
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
deleteUser();
|
apps/web/scratch/get_org_id.cjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') });
|
| 6 |
+
|
| 7 |
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
| 8 |
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
| 9 |
+
|
| 10 |
+
if (!supabaseUrl || !supabaseServiceKey) {
|
| 11 |
+
console.error('Missing Supabase credentials');
|
| 12 |
+
process.exit(1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
| 16 |
+
|
| 17 |
+
async function getOrgId() {
|
| 18 |
+
// 1. Get user ID from email
|
| 19 |
+
const { data: { users }, error: userError } = await supabase.auth.admin.listUsers();
|
| 20 |
+
if (userError) {
|
| 21 |
+
console.error('Error listing users:', userError);
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
const tester = users.find(u => u.email === 'tester@tenderhub.co.ke');
|
| 25 |
+
if (!tester) {
|
| 26 |
+
console.error('Tester user not found');
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
console.log('User ID:', tester.id);
|
| 30 |
+
|
| 31 |
+
// 2. Get organization ID from memberships
|
| 32 |
+
const { data: memberships, error: membershipError } = await supabase
|
| 33 |
+
.from('members')
|
| 34 |
+
.select('organization_id')
|
| 35 |
+
.eq('user_id', tester.id);
|
| 36 |
+
|
| 37 |
+
if (membershipError) {
|
| 38 |
+
console.error('Error getting memberships:', membershipError);
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (memberships.length === 0) {
|
| 43 |
+
console.error('No organization membership found for user');
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const orgId = memberships[0].organization_id;
|
| 48 |
+
console.log('Organization ID:', orgId);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
getOrgId();
|
apps/web/scratch/setup_org.cjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 2 |
+
const dotenv = require('dotenv');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.resolve(__dirname, '../../../.env.local') });
|
| 6 |
+
|
| 7 |
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
| 8 |
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
| 9 |
+
|
| 10 |
+
if (!supabaseUrl || !supabaseServiceKey) {
|
| 11 |
+
console.error('Missing Supabase credentials');
|
| 12 |
+
process.exit(1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
| 16 |
+
const orgId = '4584c0bd-128d-40c1-b212-4ac7d51b554e';
|
| 17 |
+
|
| 18 |
+
async function setupOrg() {
|
| 19 |
+
const branding = {
|
| 20 |
+
logo_url: 'https://tenderhubkenya.vercel.app/logo.png',
|
| 21 |
+
primary_color: '#2563eb',
|
| 22 |
+
theme: 'dark',
|
| 23 |
+
company_name: 'Test Corp - White Label'
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
console.log('Updating organization:', orgId);
|
| 27 |
+
const { data, error } = await supabase
|
| 28 |
+
.from('organizations')
|
| 29 |
+
.update({
|
| 30 |
+
plan_type: 'WHITE_LABEL',
|
| 31 |
+
white_label_branding: branding
|
| 32 |
+
})
|
| 33 |
+
.eq('id', orgId);
|
| 34 |
+
|
| 35 |
+
if (error) {
|
| 36 |
+
console.error('Error updating organization:', error);
|
| 37 |
+
} else {
|
| 38 |
+
console.log('Organization updated successfully.');
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
setupOrg();
|
apps/web/src/__tests__/api/mpesa-stk.test.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { POST as stkPush } from "../../app/api/payments/mpesa/stk/route";
|
| 2 |
+
|
| 3 |
+
describe("M-Pesa STK API", () => {
|
| 4 |
+
it("returns 400 for invalid payload", async () => {
|
| 5 |
+
// Construct a standard Fetch Request as expected by App Router
|
| 6 |
+
const req = new Request("http://localhost/api/payments/mpesa/stk", {
|
| 7 |
+
method: "POST",
|
| 8 |
+
headers: { "Content-Type": "application/json" },
|
| 9 |
+
body: JSON.stringify({}),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const res = await stkPush(req);
|
| 13 |
+
expect(res.status).toBe(400);
|
| 14 |
+
|
| 15 |
+
const data = await res.json();
|
| 16 |
+
expect(data.error).toBe("Invalid STK push payload");
|
| 17 |
+
});
|
| 18 |
+
});
|
apps/web/src/__tests__/api/tenders-drafts-export.test.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { POST as exportDrafts } from "../../app/api/tenders/[id]/drafts/export/route";
|
| 2 |
+
|
| 3 |
+
describe("Tenders Drafts Export API", () => {
|
| 4 |
+
it("returns 400 or 401 when requested incorrectly", async () => {
|
| 5 |
+
// Construct a standard Fetch Request as expected by App Router
|
| 6 |
+
const req = new Request("http://localhost/api/tenders/test-id/drafts/export", {
|
| 7 |
+
method: "POST",
|
| 8 |
+
headers: { "Content-Type": "application/json" },
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const res = await exportDrafts(req, { params: { id: "test-id" } } as any);
|
| 12 |
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
| 13 |
+
});
|
| 14 |
+
});
|
apps/web/src/__tests__/api/tenders-unlock.test.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { POST as unlockTender } from "../../app/api/tenders/[id]/unlock/route";
|
| 2 |
+
|
| 3 |
+
describe("Tenders Unlock API", () => {
|
| 4 |
+
it("returns 400 or 401 when missing proper headers/payloads", async () => {
|
| 5 |
+
// Construct a standard Fetch Request as expected by App Router
|
| 6 |
+
const req = new Request("http://localhost/api/tenders/test-id/unlock", {
|
| 7 |
+
method: "POST",
|
| 8 |
+
headers: { "Content-Type": "application/json" },
|
| 9 |
+
body: JSON.stringify({}),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const res = await unlockTender(req, { params: { id: "test-id" } } as any);
|
| 13 |
+
// Should fail cleanly (either 400 or 401)
|
| 14 |
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
| 15 |
+
});
|
| 16 |
+
});
|
apps/web/src/__tests__/api/uploads-init.test.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { POST as initUpload } from "../../app/api/uploads/init/route";
|
| 2 |
+
|
| 3 |
+
describe("Uploads API", () => {
|
| 4 |
+
it("returns 400 for invalid payload", async () => {
|
| 5 |
+
// Construct a standard Fetch Request as expected by App Router
|
| 6 |
+
const req = new Request("http://localhost/api/uploads/init", {
|
| 7 |
+
method: "POST",
|
| 8 |
+
headers: { "Content-Type": "application/json" },
|
| 9 |
+
body: JSON.stringify({}),
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const res = await initUpload(req);
|
| 13 |
+
expect(res.status).toBe(400);
|
| 14 |
+
|
| 15 |
+
const data = await res.json();
|
| 16 |
+
expect(data.error).toBe("Invalid upload init payload");
|
| 17 |
+
});
|
| 18 |
+
});
|
apps/web/src/app/(dashboard)/consultant/page.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 4 |
+
import { Badge } from "@/components/ui/badge";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Users, Building2, FileText, BarChart3, Plus } from "lucide-react";
|
| 7 |
+
|
| 8 |
+
const clients = [
|
| 9 |
+
{ name: "BuildRight Construction", tenders: 12, won: 5, active: 3 },
|
| 10 |
+
{ name: "MedSupply Kenya", tenders: 8, won: 3, active: 2 },
|
| 11 |
+
{ name: "CleanPro Services", tenders: 15, won: 7, active: 4 },
|
| 12 |
+
{ name: "TechVentures Ltd", tenders: 6, won: 2, active: 1 },
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
export default function ConsultantPortalPage() {
|
| 16 |
+
return (
|
| 17 |
+
<div className="space-y-6 animate-fade-in">
|
| 18 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
| 19 |
+
<div>
|
| 20 |
+
<div className="flex items-center gap-2 mb-1">
|
| 21 |
+
<h1 className="text-2xl font-bold text-foreground">Consultant Dashboard</h1>
|
| 22 |
+
<Badge className="bg-primary text-primary-foreground">White-Label</Badge>
|
| 23 |
+
</div>
|
| 24 |
+
<p className="text-muted-foreground text-sm">Manage multiple clients from one portal</p>
|
| 25 |
+
</div>
|
| 26 |
+
<Button className="gap-2">
|
| 27 |
+
<Plus className="h-4 w-4" /> Add Client
|
| 28 |
+
</Button>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
{/* Stats */}
|
| 32 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
| 33 |
+
<Card>
|
| 34 |
+
<CardContent className="p-4">
|
| 35 |
+
<div className="flex items-center justify-between mb-2">
|
| 36 |
+
<span className="text-sm text-muted-foreground">Total Clients</span>
|
| 37 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 38 |
+
</div>
|
| 39 |
+
<div className="text-2xl font-bold text-foreground">4</div>
|
| 40 |
+
</CardContent>
|
| 41 |
+
</Card>
|
| 42 |
+
<Card>
|
| 43 |
+
<CardContent className="p-4">
|
| 44 |
+
<div className="flex items-center justify-between mb-2">
|
| 45 |
+
<span className="text-sm text-muted-foreground">Active Tenders</span>
|
| 46 |
+
<FileText className="h-4 w-4 text-muted-foreground" />
|
| 47 |
+
</div>
|
| 48 |
+
<div className="text-2xl font-bold text-foreground">10</div>
|
| 49 |
+
</CardContent>
|
| 50 |
+
</Card>
|
| 51 |
+
<Card>
|
| 52 |
+
<CardContent className="p-4">
|
| 53 |
+
<div className="flex items-center justify-between mb-2">
|
| 54 |
+
<span className="text-sm text-muted-foreground">Total Won</span>
|
| 55 |
+
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
| 56 |
+
</div>
|
| 57 |
+
<div className="text-2xl font-bold text-success">17</div>
|
| 58 |
+
</CardContent>
|
| 59 |
+
</Card>
|
| 60 |
+
<Card>
|
| 61 |
+
<CardContent className="p-4">
|
| 62 |
+
<div className="flex items-center justify-between mb-2">
|
| 63 |
+
<span className="text-sm text-muted-foreground">Win Rate</span>
|
| 64 |
+
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
| 65 |
+
</div>
|
| 66 |
+
<div className="text-2xl font-bold text-foreground">41%</div>
|
| 67 |
+
</CardContent>
|
| 68 |
+
</Card>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Clients */}
|
| 72 |
+
<Card>
|
| 73 |
+
<CardHeader>
|
| 74 |
+
<CardTitle className="text-lg">Your Clients</CardTitle>
|
| 75 |
+
</CardHeader>
|
| 76 |
+
<CardContent>
|
| 77 |
+
<div className="space-y-3">
|
| 78 |
+
{clients.map((client) => (
|
| 79 |
+
<div key={client.name} className="flex items-center gap-4 p-4 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer">
|
| 80 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
| 81 |
+
<Building2 className="h-5 w-5 text-primary" />
|
| 82 |
+
</div>
|
| 83 |
+
<div className="flex-1 min-w-0">
|
| 84 |
+
<p className="font-medium text-foreground">{client.name}</p>
|
| 85 |
+
<p className="text-xs text-muted-foreground">{client.active} active tenders</p>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="flex items-center gap-4 text-sm shrink-0">
|
| 88 |
+
<div className="text-center hidden sm:block">
|
| 89 |
+
<div className="font-semibold text-foreground">{client.tenders}</div>
|
| 90 |
+
<div className="text-xs text-muted-foreground">Total</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div className="text-center hidden sm:block">
|
| 93 |
+
<div className="font-semibold text-success">{client.won}</div>
|
| 94 |
+
<div className="text-xs text-muted-foreground">Won</div>
|
| 95 |
+
</div>
|
| 96 |
+
<Badge variant="outline" className="text-xs">
|
| 97 |
+
{Math.round((client.won / client.tenders) * 100)}% win
|
| 98 |
+
</Badge>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
</CardContent>
|
| 104 |
+
</Card>
|
| 105 |
+
|
| 106 |
+
{/* Placeholder notice */}
|
| 107 |
+
<Card className="border-dashed border-2">
|
| 108 |
+
<CardContent className="py-12 text-center">
|
| 109 |
+
<Users className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
| 110 |
+
<h3 className="text-lg font-semibold text-foreground mb-1">White-Label Features Coming Soon</h3>
|
| 111 |
+
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
| 112 |
+
Custom branding, client-specific portals, team collaboration, and API access will be available in the Consultant tier.
|
| 113 |
+
</p>
|
| 114 |
+
</CardContent>
|
| 115 |
+
</Card>
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
}
|
apps/web/src/app/(dashboard)/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useTenders } from "@/hooks/use-tenders";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Badge } from "@/components/ui/badge";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
FileText,
|
| 9 |
+
TrendingUp,
|
| 10 |
+
AlertCircle,
|
| 11 |
+
Clock,
|
| 12 |
+
ArrowRight,
|
| 13 |
+
Plus,
|
| 14 |
+
CheckCircle2,
|
| 15 |
+
Loader2
|
| 16 |
+
} from "lucide-react";
|
| 17 |
+
import Link from "next/link";
|
| 18 |
+
|
| 19 |
+
export default function DashboardPage() {
|
| 20 |
+
const { data: tenders, isLoading } = useTenders({ limit: 5 });
|
| 21 |
+
|
| 22 |
+
// Simple stats calculation based on available data
|
| 23 |
+
const stats = {
|
| 24 |
+
totalAnalyzed: tenders?.length || 0,
|
| 25 |
+
winningChances: "72%", // Mocked for now as score needs deeper join
|
| 26 |
+
complianceAlerts: tenders?.filter(t => t.status === "FAILED").length || 0,
|
| 27 |
+
proposalsGenerated: 5 // Mocked for now
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
if (isLoading) {
|
| 31 |
+
return (
|
| 32 |
+
<div className="flex items-center justify-center h-[60vh]">
|
| 33 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 34 |
+
</div>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="space-y-6 animate-fade-in">
|
| 40 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
| 41 |
+
<div>
|
| 42 |
+
<h1 className="text-2xl font-bold text-foreground">Overview</h1>
|
| 43 |
+
<p className="text-muted-foreground text-sm">Welcome back to your tender control center</p>
|
| 44 |
+
</div>
|
| 45 |
+
<Link href="/upload">
|
| 46 |
+
<Button className="gap-2">
|
| 47 |
+
<Plus className="h-4 w-4" /> New Analysis
|
| 48 |
+
</Button>
|
| 49 |
+
</Link>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
{/* Stats Grid */}
|
| 53 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
| 54 |
+
{[
|
| 55 |
+
{ label: "Total Analyzed", value: stats.totalAnalyzed, icon: FileText, color: "text-primary" },
|
| 56 |
+
{ label: "Winning Chances", value: stats.winningChances, icon: TrendingUp, color: "text-success" },
|
| 57 |
+
{ label: "Compliance Alerts", value: stats.complianceAlerts, icon: AlertCircle, color: "text-warning" },
|
| 58 |
+
{ label: "Proposals Generated", value: stats.proposalsGenerated, icon: CheckCircle2, color: "text-primary" },
|
| 59 |
+
].map((stat) => (
|
| 60 |
+
<Card key={stat.label}>
|
| 61 |
+
<CardContent className="p-4">
|
| 62 |
+
<div className="flex items-center justify-between mb-2">
|
| 63 |
+
<span className="text-xs font-medium text-muted-foreground">{stat.label}</span>
|
| 64 |
+
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
| 65 |
+
</div>
|
| 66 |
+
<div className="text-2xl font-bold text-foreground">{stat.value}</div>
|
| 67 |
+
</CardContent>
|
| 68 |
+
</Card>
|
| 69 |
+
))}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="grid lg:grid-cols-3 gap-6">
|
| 73 |
+
{/* Recent Tenders */}
|
| 74 |
+
<Card className="lg:col-span-2">
|
| 75 |
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
| 76 |
+
<CardTitle className="text-lg font-semibold">Recent Tenders</CardTitle>
|
| 77 |
+
<Link href="/tenders">
|
| 78 |
+
<Button variant="ghost" size="sm" className="text-xs text-primary gap-1">
|
| 79 |
+
View All <ArrowRight className="h-3 w-3" />
|
| 80 |
+
</Button>
|
| 81 |
+
</Link>
|
| 82 |
+
</CardHeader>
|
| 83 |
+
<CardContent>
|
| 84 |
+
<div className="space-y-4">
|
| 85 |
+
{tenders?.length === 0 ? (
|
| 86 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 87 |
+
No tenders found. Upload your first tender PDF to get started.
|
| 88 |
+
</div>
|
| 89 |
+
) : (
|
| 90 |
+
tenders?.map((tender) => (
|
| 91 |
+
<Link key={tender.tenderId} href={`/tenders/${tender.tenderId}`}>
|
| 92 |
+
<div className="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer group">
|
| 93 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
| 94 |
+
<FileText className="h-5 w-5 text-primary" />
|
| 95 |
+
</div>
|
| 96 |
+
<div className="flex-1 min-w-0">
|
| 97 |
+
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">
|
| 98 |
+
{tender.fileName}
|
| 99 |
+
</p>
|
| 100 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 101 |
+
<span className="text-xs text-muted-foreground">Updated {new Date(tender.updatedAt).toLocaleDateString()}</span>
|
| 102 |
+
<span className="text-xs text-muted-foreground">•</span>
|
| 103 |
+
{tender.unlocked ? (
|
| 104 |
+
<Badge variant="secondary" className="text-[10px] h-4">Unlocked</Badge>
|
| 105 |
+
) : (
|
| 106 |
+
<Badge variant="outline" className="text-[10px] h-4">Locked</Badge>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
<div className="flex items-center gap-3 shrink-0">
|
| 111 |
+
<div className="text-right">
|
| 112 |
+
<div className="text-sm font-semibold text-success">{tender.score || "--"}</div>
|
| 113 |
+
<div className="text-[10px] text-muted-foreground">Score</div>
|
| 114 |
+
</div>
|
| 115 |
+
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</Link>
|
| 119 |
+
))
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
</CardContent>
|
| 123 |
+
</Card>
|
| 124 |
+
|
| 125 |
+
{/* System Status / Upgrades */}
|
| 126 |
+
<Card className="bg-primary/5 border-primary/10">
|
| 127 |
+
<CardHeader>
|
| 128 |
+
<CardTitle className="text-lg font-semibold text-primary">Win More Tenders</CardTitle>
|
| 129 |
+
</CardHeader>
|
| 130 |
+
<CardContent className="space-y-4">
|
| 131 |
+
<p className="text-sm text-muted-foreground">
|
| 132 |
+
Upgrade to the <span className="font-semibold text-foreground">Unlimited Plan</span> to access deep compliance validation and AI-powered proposal scoring.
|
| 133 |
+
</p>
|
| 134 |
+
<div className="space-y-2">
|
| 135 |
+
<div className="flex items-center gap-2 text-xs text-foreground">
|
| 136 |
+
<CheckCircle2 className="h-4 w-4 text-success" /> Unlimited AI Analysis
|
| 137 |
+
</div>
|
| 138 |
+
<div className="flex items-center gap-2 text-xs text-foreground">
|
| 139 |
+
<CheckCircle2 className="h-4 w-4 text-success" /> Export to PDF/Word
|
| 140 |
+
</div>
|
| 141 |
+
<div className="flex items-center gap-2 text-xs text-foreground">
|
| 142 |
+
<CheckCircle2 className="h-4 w-4 text-success" /> Consultant Portal Access
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
<Button className="w-full mt-4 bg-primary hover:bg-primary/90">Upgrade Now</Button>
|
| 146 |
+
</CardContent>
|
| 147 |
+
</Card>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function ChevronRight(props: any) {
|
| 154 |
+
return (
|
| 155 |
+
<svg
|
| 156 |
+
{...props}
|
| 157 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 158 |
+
width="24"
|
| 159 |
+
height="24"
|
| 160 |
+
viewBox="0 0 24 24"
|
| 161 |
+
fill="none"
|
| 162 |
+
stroke="currentColor"
|
| 163 |
+
strokeWidth="2"
|
| 164 |
+
strokeLinecap="round"
|
| 165 |
+
strokeLinejoin="round"
|
| 166 |
+
>
|
| 167 |
+
<path d="m9 18 6-6-6-6" />
|
| 168 |
+
</svg>
|
| 169 |
+
);
|
| 170 |
+
}
|
apps/web/src/app/(dashboard)/layout.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
| 4 |
+
import { AppSidebar } from "@/components/AppSidebar";
|
| 5 |
+
|
| 6 |
+
export default function DashboardLayout({
|
| 7 |
+
children,
|
| 8 |
+
}: {
|
| 9 |
+
children: React.ReactNode;
|
| 10 |
+
}) {
|
| 11 |
+
return (
|
| 12 |
+
<SidebarProvider>
|
| 13 |
+
<div className="min-h-screen flex w-full">
|
| 14 |
+
<AppSidebar />
|
| 15 |
+
<div className="flex-1 flex flex-col min-w-0">
|
| 16 |
+
<header className="h-14 flex items-center border-b bg-card px-4 gap-3">
|
| 17 |
+
<SidebarTrigger />
|
| 18 |
+
<div className="flex items-center gap-2">
|
| 19 |
+
<span className="text-sm font-medium text-muted-foreground">TenderHub</span>
|
| 20 |
+
</div>
|
| 21 |
+
</header>
|
| 22 |
+
<main className="flex-1 p-4 md:p-6 overflow-auto">
|
| 23 |
+
{children}
|
| 24 |
+
</main>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
</SidebarProvider>
|
| 28 |
+
);
|
| 29 |
+
}
|
apps/web/src/app/(dashboard)/proposals/[id]/page.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { use, useState, useEffect } from "react";
|
| 4 |
+
import { useTenderDrafts, useGenerateDraft } from "@/hooks/use-proposals";
|
| 5 |
+
import { useTenderStatus } from "@/hooks/use-tenders";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 11 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 12 |
+
import { ArrowLeft, Download, FileText, Edit3, Save, Loader2, Sparkles, AlertTriangle } from "lucide-react";
|
| 13 |
+
import Link from "next/link";
|
| 14 |
+
import { toast } from "sonner";
|
| 15 |
+
|
| 16 |
+
export default function ProposalBuilderPage({ params }: { params: Promise<{ id: string }> }) {
|
| 17 |
+
const { id } = use(params);
|
| 18 |
+
const { data: statusData } = useTenderStatus(id);
|
| 19 |
+
const { data: draftsData, isLoading: draftsLoading, error: draftsError } = useTenderDrafts(id);
|
| 20 |
+
const generateMutation = useGenerateDraft(id);
|
| 21 |
+
|
| 22 |
+
const [activeTab, setActiveTab] = useState("EXECUTIVE_SUMMARY");
|
| 23 |
+
const [tone, setTone] = useState("professional");
|
| 24 |
+
const [editingContent, setEditingContent] = useState<string>("");
|
| 25 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 26 |
+
|
| 27 |
+
// Sync editing content when tab changes or drafts load
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
const currentDraft = draftsData?.drafts.find(d => d.section === activeTab);
|
| 30 |
+
setEditingContent(currentDraft?.content || "");
|
| 31 |
+
setIsEditing(false);
|
| 32 |
+
}, [activeTab, draftsData]);
|
| 33 |
+
|
| 34 |
+
const handleGenerate = () => {
|
| 35 |
+
generateMutation.mutate({ section: activeTab, tone }, {
|
| 36 |
+
onSuccess: () => {
|
| 37 |
+
toast.success(`Generated ${activeTab.replace("_", " ")} successfully!`);
|
| 38 |
+
},
|
| 39 |
+
onError: (err) => {
|
| 40 |
+
toast.error(`Generation failed: ${err.message}`);
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
if (draftsLoading) {
|
| 46 |
+
return (
|
| 47 |
+
<div className="flex flex-col items-center justify-center h-[70vh] gap-4">
|
| 48 |
+
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
| 49 |
+
<p className="text-muted-foreground animate-pulse">Loading proposal drafts...</p>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (draftsError?.message === "PAYMENT_REQUIRED") {
|
| 55 |
+
return (
|
| 56 |
+
<div className="flex flex-col items-center justify-center h-[70vh] gap-6 text-center max-w-md mx-auto">
|
| 57 |
+
<AlertTriangle className="h-12 w-12 text-warning" />
|
| 58 |
+
<h2 className="text-2xl font-bold">Proposal Builder Locked</h2>
|
| 59 |
+
<p className="text-muted-foreground">
|
| 60 |
+
You need to unlock the analysis for this tender before you can generate AI-powered proposal drafts.
|
| 61 |
+
</p>
|
| 62 |
+
<Link href={`/tenders/${id}`}>
|
| 63 |
+
<Button size="lg">Unlock Analysis First</Button>
|
| 64 |
+
</Link>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const sections = [
|
| 70 |
+
{ id: "EXECUTIVE_SUMMARY", title: "Executive Summary" },
|
| 71 |
+
{ id: "TECHNICAL_APPROACH", title: "Technical Approach" },
|
| 72 |
+
{ id: "PROJECT_TEAM", title: "Project Team" },
|
| 73 |
+
{ id: "WORKPLAN", title: "Workplan" },
|
| 74 |
+
{ id: "COMMERCIAL_RESPONSE", title: "Commercial Response" },
|
| 75 |
+
];
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="space-y-6 animate-fade-in">
|
| 79 |
+
{/* Header */}
|
| 80 |
+
<div className="flex flex-col gap-4">
|
| 81 |
+
<Link href={`/tenders/${id}`} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit">
|
| 82 |
+
<ArrowLeft className="h-4 w-4" /> Back to Analysis
|
| 83 |
+
</Link>
|
| 84 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
| 85 |
+
<div>
|
| 86 |
+
<h1 className="text-xl md:text-2xl font-bold text-foreground">Proposal Builder</h1>
|
| 87 |
+
<p className="text-muted-foreground text-sm">{statusData?.fileName || "Tender Document"}</p>
|
| 88 |
+
</div>
|
| 89 |
+
<div className="flex items-center gap-3">
|
| 90 |
+
<Select value={tone} onValueChange={setTone}>
|
| 91 |
+
<SelectTrigger className="w-32 h-9">
|
| 92 |
+
<SelectValue placeholder="Tone" />
|
| 93 |
+
</SelectTrigger>
|
| 94 |
+
<SelectContent>
|
| 95 |
+
<SelectItem value="professional">Professional</SelectItem>
|
| 96 |
+
<SelectItem value="persuasive">Persuasive</SelectItem>
|
| 97 |
+
<SelectItem value="technical">Technical</SelectItem>
|
| 98 |
+
</SelectContent>
|
| 99 |
+
</Select>
|
| 100 |
+
<Button variant="outline" className="gap-2 h-9" disabled>
|
| 101 |
+
<Download className="h-4 w-4" /> Export
|
| 102 |
+
</Button>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Proposal Editor */}
|
| 108 |
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
| 109 |
+
<TabsList className="w-full justify-start flex-wrap h-auto gap-1 bg-transparent p-0">
|
| 110 |
+
{sections.map((section) => (
|
| 111 |
+
<TabsTrigger
|
| 112 |
+
key={section.id}
|
| 113 |
+
value={section.id}
|
| 114 |
+
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-lg px-4"
|
| 115 |
+
>
|
| 116 |
+
{section.title}
|
| 117 |
+
</TabsTrigger>
|
| 118 |
+
))}
|
| 119 |
+
</TabsList>
|
| 120 |
+
|
| 121 |
+
{sections.map((section) => (
|
| 122 |
+
<TabsContent key={section.id} value={section.id}>
|
| 123 |
+
<Card>
|
| 124 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
| 125 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 126 |
+
<FileText className="h-5 w-5 text-primary" />
|
| 127 |
+
{section.title}
|
| 128 |
+
</CardTitle>
|
| 129 |
+
<div className="flex items-center gap-2">
|
| 130 |
+
<Button
|
| 131 |
+
variant="outline"
|
| 132 |
+
size="sm"
|
| 133 |
+
className="gap-1.5 bg-primary/5 text-primary border-primary/20 hover:bg-primary/10"
|
| 134 |
+
onClick={handleGenerate}
|
| 135 |
+
disabled={generateMutation.isPending}
|
| 136 |
+
>
|
| 137 |
+
{generateMutation.isPending ? (
|
| 138 |
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
| 139 |
+
) : (
|
| 140 |
+
<Sparkles className="h-3.5 w-3.5" />
|
| 141 |
+
)}
|
| 142 |
+
{editingContent ? "Regenerate" : "Generate Draft"}
|
| 143 |
+
</Button>
|
| 144 |
+
{editingContent && (
|
| 145 |
+
<Button
|
| 146 |
+
variant="ghost"
|
| 147 |
+
size="sm"
|
| 148 |
+
className="gap-1"
|
| 149 |
+
onClick={() => setIsEditing(!isEditing)}
|
| 150 |
+
>
|
| 151 |
+
{isEditing ? (
|
| 152 |
+
<>
|
| 153 |
+
<Save className="h-4 w-4" /> Save
|
| 154 |
+
</>
|
| 155 |
+
) : (
|
| 156 |
+
<>
|
| 157 |
+
<Edit3 className="h-4 w-4" /> Edit
|
| 158 |
+
</>
|
| 159 |
+
)}
|
| 160 |
+
</Button>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
</CardHeader>
|
| 164 |
+
<CardContent>
|
| 165 |
+
{generateMutation.isPending && !editingContent ? (
|
| 166 |
+
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-muted-foreground border rounded-lg border-dashed">
|
| 167 |
+
<Sparkles className="h-8 w-8 animate-pulse text-primary" />
|
| 168 |
+
<p>AI is drafting your proposal section...</p>
|
| 169 |
+
</div>
|
| 170 |
+
) : editingContent ? (
|
| 171 |
+
isEditing ? (
|
| 172 |
+
<Textarea
|
| 173 |
+
value={editingContent}
|
| 174 |
+
onChange={(e) => setEditingContent(e.target.value)}
|
| 175 |
+
className="min-h-[500px] font-mono text-sm"
|
| 176 |
+
/>
|
| 177 |
+
) : (
|
| 178 |
+
<div className="bg-muted/30 rounded-lg p-6">
|
| 179 |
+
<pre className="whitespace-pre-wrap text-sm text-foreground leading-relaxed font-sans">
|
| 180 |
+
{editingContent}
|
| 181 |
+
</pre>
|
| 182 |
+
</div>
|
| 183 |
+
)
|
| 184 |
+
) : (
|
| 185 |
+
<div className="min-h-[400px] flex flex-col items-center justify-center gap-4 text-center border-2 border-dashed rounded-xl">
|
| 186 |
+
<Sparkles className="h-10 w-10 text-muted-foreground" />
|
| 187 |
+
<div className="space-y-1">
|
| 188 |
+
<h3 className="font-semibold">No Draft Ready</h3>
|
| 189 |
+
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
| 190 |
+
Click "Generate Draft" to use AI to write this section based on the tender requirements.
|
| 191 |
+
</p>
|
| 192 |
+
</div>
|
| 193 |
+
<Button onClick={handleGenerate} size="sm">Generate with AI</Button>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
</TabsContent>
|
| 199 |
+
))}
|
| 200 |
+
</Tabs>
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
apps/web/src/app/(dashboard)/proposals/page.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useProposals } from "@/hooks/use-proposals";
|
| 4 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 5 |
+
import { Badge } from "@/components/ui/badge";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { FileEdit, Clock, ArrowRight, Plus, Loader2 } from "lucide-react";
|
| 8 |
+
import Link from "next/link";
|
| 9 |
+
|
| 10 |
+
export default function ProposalsListPage() {
|
| 11 |
+
const { data: proposals, isLoading } = useProposals();
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="space-y-6 animate-fade-in">
|
| 15 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
| 16 |
+
<div>
|
| 17 |
+
<h1 className="text-2xl font-bold text-foreground">Proposals</h1>
|
| 18 |
+
<p className="text-muted-foreground text-sm">Manage your generated proposal drafts</p>
|
| 19 |
+
</div>
|
| 20 |
+
<Link href="/upload">
|
| 21 |
+
<Button className="gap-2">
|
| 22 |
+
<Plus className="h-4 w-4" /> New Proposal
|
| 23 |
+
</Button>
|
| 24 |
+
</Link>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div className="space-y-3">
|
| 28 |
+
{isLoading ? (
|
| 29 |
+
<div className="flex justify-center py-12">
|
| 30 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 31 |
+
</div>
|
| 32 |
+
) : proposals?.length === 0 ? (
|
| 33 |
+
<div className="text-center py-12 border-2 border-dashed rounded-xl">
|
| 34 |
+
<FileEdit className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
| 35 |
+
<p className="text-muted-foreground">No proposals found. Analyze a tender to start drafting.</p>
|
| 36 |
+
<Link href="/upload">
|
| 37 |
+
<Button variant="link" className="mt-1">Upload a tender document</Button>
|
| 38 |
+
</Link>
|
| 39 |
+
</div>
|
| 40 |
+
) : (
|
| 41 |
+
proposals?.map((p: any) => (
|
| 42 |
+
<Link key={p.tenderId} href={`/proposals/${p.tenderId}`}>
|
| 43 |
+
<Card className="hover:shadow-md transition-all hover:border-primary/20 cursor-pointer">
|
| 44 |
+
<CardContent className="p-4 flex items-center gap-4">
|
| 45 |
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
| 46 |
+
<FileEdit className="h-5 w-5 text-primary" />
|
| 47 |
+
</div>
|
| 48 |
+
<div className="flex-1 min-w-0">
|
| 49 |
+
<p className="text-sm font-medium text-foreground truncate">{p.fileName}</p>
|
| 50 |
+
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
| 51 |
+
<Clock className="h-3 w-3" /> Updated {new Date(p.updatedAt).toLocaleDateString()}
|
| 52 |
+
</span>
|
| 53 |
+
</div>
|
| 54 |
+
<div className="flex items-center gap-3 shrink-0">
|
| 55 |
+
<Badge variant="outline" className="text-xs">
|
| 56 |
+
{p.unlocked ? "Analysis Ready" : "Locked"}
|
| 57 |
+
</Badge>
|
| 58 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground hidden sm:block" />
|
| 59 |
+
</div>
|
| 60 |
+
</CardContent>
|
| 61 |
+
</Card>
|
| 62 |
+
</Link>
|
| 63 |
+
))
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
apps/web/src/app/(dashboard)/settings/page.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { useCompanyProfile, useUpdateCompanyProfile, CompanyProfile } from "@/hooks/use-company-profile";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Label } from "@/components/ui/label";
|
| 8 |
+
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 11 |
+
import { Separator } from "@/components/ui/separator";
|
| 12 |
+
import { Building2, CreditCard, User, Loader2, Save, CheckCircle2 } from "lucide-react";
|
| 13 |
+
import { toast } from "sonner";
|
| 14 |
+
import { useAuth } from "@/components/AuthProvider";
|
| 15 |
+
|
| 16 |
+
export default function SettingsPage() {
|
| 17 |
+
const { user } = useAuth();
|
| 18 |
+
const { data: profile, isLoading } = useCompanyProfile();
|
| 19 |
+
const updateMutation = useUpdateCompanyProfile();
|
| 20 |
+
|
| 21 |
+
const [formData, setFormData] = useState<CompanyProfile>({
|
| 22 |
+
legalName: "",
|
| 23 |
+
kraPin: "",
|
| 24 |
+
agpoCategory: "none",
|
| 25 |
+
ncaCategory: "none",
|
| 26 |
+
counties: [],
|
| 27 |
+
teamSize: 0,
|
| 28 |
+
pastProjectsSummary: "",
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
if (profile) {
|
| 33 |
+
setFormData({
|
| 34 |
+
legalName: profile.legalName || "",
|
| 35 |
+
kraPin: profile.kraPin || "",
|
| 36 |
+
agpoCategory: profile.agpoCategory || "none",
|
| 37 |
+
ncaCategory: profile.ncaCategory || "none",
|
| 38 |
+
counties: profile.counties || [],
|
| 39 |
+
teamSize: profile.teamSize || 0,
|
| 40 |
+
pastProjectsSummary: profile.pastProjectsSummary || "",
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
}, [profile]);
|
| 44 |
+
|
| 45 |
+
const handleSaveProfile = () => {
|
| 46 |
+
updateMutation.mutate(formData, {
|
| 47 |
+
onSuccess: () => {
|
| 48 |
+
toast.success("Profile updated successfully!");
|
| 49 |
+
},
|
| 50 |
+
onError: (err) => {
|
| 51 |
+
toast.error(`Failed to update profile: ${err.message}`);
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
if (isLoading) {
|
| 57 |
+
return (
|
| 58 |
+
<div className="flex justify-center items-center h-[60vh]">
|
| 59 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="space-y-6 animate-fade-in max-w-3xl pb-10">
|
| 66 |
+
<div>
|
| 67 |
+
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
| 68 |
+
<p className="text-muted-foreground text-sm">Manage your company profile and account</p>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Company Profile */}
|
| 72 |
+
<Card>
|
| 73 |
+
<CardHeader>
|
| 74 |
+
<div className="flex items-center justify-between">
|
| 75 |
+
<div className="space-y-1">
|
| 76 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 77 |
+
<Building2 className="h-5 w-5 text-primary" /> Company Profile
|
| 78 |
+
</CardTitle>
|
| 79 |
+
<CardDescription>These details are used for Bid/No-Bid scoring</CardDescription>
|
| 80 |
+
</div>
|
| 81 |
+
<Button
|
| 82 |
+
size="sm"
|
| 83 |
+
className="gap-2"
|
| 84 |
+
onClick={handleSaveProfile}
|
| 85 |
+
disabled={updateMutation.isPending}
|
| 86 |
+
>
|
| 87 |
+
{updateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
| 88 |
+
Save Changes
|
| 89 |
+
</Button>
|
| 90 |
+
</div>
|
| 91 |
+
</CardHeader>
|
| 92 |
+
<Separator />
|
| 93 |
+
<CardContent className="space-y-4 pt-6">
|
| 94 |
+
<div className="grid sm:grid-cols-2 gap-4">
|
| 95 |
+
<div className="space-y-2">
|
| 96 |
+
<Label htmlFor="companyName">Legal Company Name</Label>
|
| 97 |
+
<Input
|
| 98 |
+
id="companyName"
|
| 99 |
+
value={formData.legalName}
|
| 100 |
+
onChange={(e) => setFormData({...formData, legalName: e.target.value})}
|
| 101 |
+
placeholder="e.g. TechPro Solutions Ltd"
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="space-y-2">
|
| 105 |
+
<Label htmlFor="taxPin">KRA Tax PIN</Label>
|
| 106 |
+
<Input
|
| 107 |
+
id="taxPin"
|
| 108 |
+
value={formData.kraPin}
|
| 109 |
+
onChange={(e) => setFormData({...formData, kraPin: e.target.value})}
|
| 110 |
+
placeholder="e.g. P051234567X"
|
| 111 |
+
/>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
<div className="grid sm:grid-cols-2 gap-4">
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
<Label htmlFor="agpo">AGPO Category</Label>
|
| 117 |
+
<Select
|
| 118 |
+
value={formData.agpoCategory}
|
| 119 |
+
onValueChange={(val) => setFormData({...formData, agpoCategory: val})}
|
| 120 |
+
>
|
| 121 |
+
<SelectTrigger>
|
| 122 |
+
<SelectValue placeholder="Select category" />
|
| 123 |
+
</SelectTrigger>
|
| 124 |
+
<SelectContent>
|
| 125 |
+
<SelectItem value="youth">Youth</SelectItem>
|
| 126 |
+
<SelectItem value="women">Women</SelectItem>
|
| 127 |
+
<SelectItem value="pwd">Persons with Disability</SelectItem>
|
| 128 |
+
<SelectItem value="none">Not Registered</SelectItem>
|
| 129 |
+
</SelectContent>
|
| 130 |
+
</Select>
|
| 131 |
+
</div>
|
| 132 |
+
<div className="space-y-2">
|
| 133 |
+
<Label htmlFor="nca">NCA Registration Class</Label>
|
| 134 |
+
<Select
|
| 135 |
+
value={formData.ncaCategory}
|
| 136 |
+
onValueChange={(val) => setFormData({...formData, ncaCategory: val})}
|
| 137 |
+
>
|
| 138 |
+
<SelectTrigger>
|
| 139 |
+
<SelectValue placeholder="Select class" />
|
| 140 |
+
</SelectTrigger>
|
| 141 |
+
<SelectContent>
|
| 142 |
+
<SelectItem value="nca1">NCA 1</SelectItem>
|
| 143 |
+
<SelectItem value="nca2">NCA 2</SelectItem>
|
| 144 |
+
<SelectItem value="nca3">NCA 3</SelectItem>
|
| 145 |
+
<SelectItem value="nca4">NCA 4</SelectItem>
|
| 146 |
+
<SelectItem value="nca5">NCA 5</SelectItem>
|
| 147 |
+
<SelectItem value="nca6">NCA 6</SelectItem>
|
| 148 |
+
<SelectItem value="nca7">NCA 7</SelectItem>
|
| 149 |
+
<SelectItem value="nca8">NCA 8</SelectItem>
|
| 150 |
+
<SelectItem value="none">Not Registered</SelectItem>
|
| 151 |
+
</SelectContent>
|
| 152 |
+
</Select>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div className="grid sm:grid-cols-2 gap-4">
|
| 156 |
+
<div className="space-y-2">
|
| 157 |
+
<Label htmlFor="employees">Number of Employees</Label>
|
| 158 |
+
<Input
|
| 159 |
+
id="employees"
|
| 160 |
+
type="number"
|
| 161 |
+
value={formData.teamSize}
|
| 162 |
+
onChange={(e) => setFormData({...formData, teamSize: parseInt(e.target.value) || 0})}
|
| 163 |
+
/>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</CardContent>
|
| 167 |
+
</Card>
|
| 168 |
+
|
| 169 |
+
{/* Subscription */}
|
| 170 |
+
<Card>
|
| 171 |
+
<CardHeader>
|
| 172 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 173 |
+
<CreditCard className="h-5 w-5 text-primary" /> Subscription
|
| 174 |
+
</CardTitle>
|
| 175 |
+
</CardHeader>
|
| 176 |
+
<CardContent className="space-y-4">
|
| 177 |
+
<div className="flex items-center justify-between p-4 rounded-lg bg-primary/5 border border-primary/10">
|
| 178 |
+
<div>
|
| 179 |
+
<div className="flex items-center gap-2">
|
| 180 |
+
<span className="font-semibold text-foreground">Unlimited Plan</span>
|
| 181 |
+
<Badge className="bg-success text-success-foreground">Active</Badge>
|
| 182 |
+
</div>
|
| 183 |
+
<p className="text-sm text-muted-foreground mt-1">KES 4,999/month · Renews Soon</p>
|
| 184 |
+
</div>
|
| 185 |
+
<Button variant="outline" size="sm">Manage Billing</Button>
|
| 186 |
+
</div>
|
| 187 |
+
</CardContent>
|
| 188 |
+
</Card>
|
| 189 |
+
|
| 190 |
+
{/* Account */}
|
| 191 |
+
<Card>
|
| 192 |
+
<CardHeader>
|
| 193 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 194 |
+
<User className="h-5 w-5 text-primary" /> Account
|
| 195 |
+
</CardTitle>
|
| 196 |
+
</CardHeader>
|
| 197 |
+
<CardContent className="space-y-4">
|
| 198 |
+
<div className="grid sm:grid-cols-2 gap-4">
|
| 199 |
+
<div className="space-y-2">
|
| 200 |
+
<Label htmlFor="name">Full Name</Label>
|
| 201 |
+
<Input id="name" defaultValue={user?.user_metadata?.full_name || "James Kamau"} disabled />
|
| 202 |
+
</div>
|
| 203 |
+
<div className="space-y-2">
|
| 204 |
+
<Label htmlFor="email">Email</Label>
|
| 205 |
+
<Input id="email" type="email" defaultValue={user?.email || ""} disabled />
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
<p className="text-xs text-muted-foreground">Contact support to change your account email or phone number.</p>
|
| 209 |
+
<Separator />
|
| 210 |
+
<div className="flex gap-3">
|
| 211 |
+
<Button
|
| 212 |
+
variant="outline"
|
| 213 |
+
className="text-destructive hover:bg-destructive/10 border-destructive/20"
|
| 214 |
+
onClick={() => signOut()}
|
| 215 |
+
>
|
| 216 |
+
Sign Out
|
| 217 |
+
</Button>
|
| 218 |
+
</div>
|
| 219 |
+
</CardContent>
|
| 220 |
+
</Card>
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
apps/web/src/app/(dashboard)/tenders/[id]/page.tsx
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { use, useState, useEffect } from "react";
|
| 4 |
+
import { useTenderAnalysis, useTenderStatus } from "@/hooks/use-tenders";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 9 |
+
import {
|
| 10 |
+
CheckCircle2,
|
| 11 |
+
XCircle,
|
| 12 |
+
AlertTriangle,
|
| 13 |
+
FileEdit,
|
| 14 |
+
Trophy,
|
| 15 |
+
Shield,
|
| 16 |
+
ArrowLeft,
|
| 17 |
+
Clock,
|
| 18 |
+
Calendar,
|
| 19 |
+
BarChart3,
|
| 20 |
+
Loader2,
|
| 21 |
+
Lock
|
| 22 |
+
} from "lucide-react";
|
| 23 |
+
import {
|
| 24 |
+
Table,
|
| 25 |
+
TableBody,
|
| 26 |
+
TableCell,
|
| 27 |
+
TableHead,
|
| 28 |
+
TableHeader,
|
| 29 |
+
TableRow,
|
| 30 |
+
} from "@/components/ui/table";
|
| 31 |
+
import Link from "next/link";
|
| 32 |
+
import ScoreGauge from "@/components/tender/ScoreGauge";
|
| 33 |
+
import TenderSummaryCard from "@/components/tender/TenderSummaryCard";
|
| 34 |
+
import DeadlinesTimeline from "@/components/tender/DeadlinesTimeline";
|
| 35 |
+
import BudgetCard from "@/components/tender/BudgetCard";
|
| 36 |
+
import EligibilitySection from "@/components/tender/EligibilitySection";
|
| 37 |
+
import EvaluationCriteria from "@/components/tender/EvaluationCriteria";
|
| 38 |
+
import ValidationAgent from "@/components/tender/ValidationAgent";
|
| 39 |
+
|
| 40 |
+
export default function TenderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
| 41 |
+
const { id } = use(params);
|
| 42 |
+
const { data: statusData, isLoading: statusLoading } = useTenderStatus(id);
|
| 43 |
+
const { data: analysisData, isLoading: analysisLoading, error: analysisError } = useTenderAnalysis(id);
|
| 44 |
+
|
| 45 |
+
if (statusLoading || analysisLoading) {
|
| 46 |
+
return (
|
| 47 |
+
<div className="flex flex-col items-center justify-center h-[70vh] gap-4">
|
| 48 |
+
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
| 49 |
+
<p className="text-muted-foreground animate-pulse">Analyzing tender document...</p>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Handle Locked State
|
| 55 |
+
if (analysisError?.message === "PAYMENT_REQUIRED" || statusData?.unlocked === false) {
|
| 56 |
+
return (
|
| 57 |
+
<div className="flex flex-col items-center justify-center h-[70vh] gap-6 text-center max-w-md mx-auto">
|
| 58 |
+
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
|
| 59 |
+
<Lock className="h-10 w-10 text-primary" />
|
| 60 |
+
</div>
|
| 61 |
+
<div className="space-y-2">
|
| 62 |
+
<h2 className="text-2xl font-bold">Analysis Locked</h2>
|
| 63 |
+
<p className="text-muted-foreground">
|
| 64 |
+
Complete the payment to unlock the full AI-powered analysis, compliance matrix, and proposal builder for this tender.
|
| 65 |
+
</p>
|
| 66 |
+
</div>
|
| 67 |
+
<Button className="w-full gap-2" size="lg">
|
| 68 |
+
Unlock Analysis (KES 1,500)
|
| 69 |
+
</Button>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Handle Processing State
|
| 75 |
+
if (statusData?.status === "PROCESSING" || statusData?.status === "UPLOADED") {
|
| 76 |
+
return (
|
| 77 |
+
<div className="flex flex-col items-center justify-center h-[70vh] gap-6 text-center max-w-md mx-auto">
|
| 78 |
+
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
| 79 |
+
<div className="space-y-2">
|
| 80 |
+
<h2 className="text-2xl font-bold">Processing...</h2>
|
| 81 |
+
<p className="text-muted-foreground">
|
| 82 |
+
Our AI is currently extracting requirements and calculating your bid score. This usually takes 30-60 seconds.
|
| 83 |
+
</p>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (!analysisData) {
|
| 90 |
+
return (
|
| 91 |
+
<div className="text-center py-20">
|
| 92 |
+
<AlertTriangle className="h-12 w-12 text-warning mx-auto mb-4" />
|
| 93 |
+
<h2 className="text-xl font-bold">Analysis Unavailable</h2>
|
| 94 |
+
<p className="text-muted-foreground">We couldn't load the analysis for this tender.</p>
|
| 95 |
+
<Button variant="outline" className="mt-4" onClick={() => window.location.reload()}>Retry</Button>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const analysis = analysisData;
|
| 101 |
+
const tender = {
|
| 102 |
+
title: statusData?.fileName || "Tender Document",
|
| 103 |
+
entity: analysis.score?.procuringEntity || "Government Agency",
|
| 104 |
+
referenceNo: analysis.score?.referenceNo || "REF-001",
|
| 105 |
+
closingDate: analysis.score?.submissionDeadline || "2026-04-30",
|
| 106 |
+
category: analysis.score?.tenderCategory || "General",
|
| 107 |
+
score: analysis.bidScore?.score || 0,
|
| 108 |
+
summary: analysis.summary || "No summary available.",
|
| 109 |
+
procurementMethod: "Open National Tender",
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="space-y-6 animate-fade-in">
|
| 114 |
+
{/* Header */}
|
| 115 |
+
<div className="flex flex-col gap-4">
|
| 116 |
+
<Link href="/tenders" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit">
|
| 117 |
+
<ArrowLeft className="h-4 w-4" /> Back to Tenders
|
| 118 |
+
</Link>
|
| 119 |
+
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
| 120 |
+
<div>
|
| 121 |
+
<h1 className="text-xl md:text-2xl font-bold text-foreground">{tender.title}</h1>
|
| 122 |
+
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
| 123 |
+
<span className="text-sm text-muted-foreground">{tender.entity}</span>
|
| 124 |
+
<Badge variant="outline">{tender.category}</Badge>
|
| 125 |
+
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
| 126 |
+
<Clock className="h-3 w-3" /> Closes {new Date(tender.closingDate).toLocaleDateString()}
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<div className="flex gap-2">
|
| 131 |
+
<Link href={`/proposals/${id}`}>
|
| 132 |
+
<Button className="gap-2">
|
| 133 |
+
<FileEdit className="h-4 w-4" /> Generate Proposal
|
| 134 |
+
</Button>
|
| 135 |
+
</Link>
|
| 136 |
+
<Button variant="outline" className="gap-2">
|
| 137 |
+
<Trophy className="h-4 w-4" /> Mark Won
|
| 138 |
+
</Button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{/* Validation Agent */}
|
| 144 |
+
<ValidationAgent referenceNo={tender.referenceNo} />
|
| 145 |
+
|
| 146 |
+
{/* Summary + Score Row */}
|
| 147 |
+
<div className="grid lg:grid-cols-3 gap-6">
|
| 148 |
+
<TenderSummaryCard
|
| 149 |
+
summary={tender.summary}
|
| 150 |
+
entity={tender.entity}
|
| 151 |
+
method={tender.procurementMethod}
|
| 152 |
+
category={tender.category}
|
| 153 |
+
referenceNo={tender.referenceNo}
|
| 154 |
+
/>
|
| 155 |
+
<Card>
|
| 156 |
+
<CardHeader>
|
| 157 |
+
<CardTitle className="text-lg">Bid/No-Bid Score</CardTitle>
|
| 158 |
+
</CardHeader>
|
| 159 |
+
<CardContent>
|
| 160 |
+
<ScoreGauge score={tender.score} />
|
| 161 |
+
<p className="text-center text-sm text-muted-foreground mt-4">
|
| 162 |
+
{analysis.bidScore?.explanation || (tender.score >= 70
|
| 163 |
+
? "Strong match — recommended to bid"
|
| 164 |
+
: tender.score >= 40
|
| 165 |
+
? "Moderate match — review red flags"
|
| 166 |
+
: "Weak match — consider skipping")}
|
| 167 |
+
</p>
|
| 168 |
+
</CardContent>
|
| 169 |
+
</Card>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* Tabbed Sections */}
|
| 173 |
+
<Tabs defaultValue="overview" className="w-full">
|
| 174 |
+
<TabsList className="w-full justify-start flex-wrap h-auto gap-1 bg-muted/50 p-1">
|
| 175 |
+
<TabsTrigger value="overview" className="gap-1.5">
|
| 176 |
+
<Calendar className="h-3.5 w-3.5" /> Overview
|
| 177 |
+
</TabsTrigger>
|
| 178 |
+
<TabsTrigger value="compliance" className="gap-1.5">
|
| 179 |
+
<Shield className="h-3.5 w-3.5" /> Compliance
|
| 180 |
+
</TabsTrigger>
|
| 181 |
+
<TabsTrigger value="red-flags" className="gap-1.5">
|
| 182 |
+
<AlertTriangle className="h-3.5 w-3.5" /> Red Flags
|
| 183 |
+
</TabsTrigger>
|
| 184 |
+
<TabsTrigger value="evaluation" className="gap-1.5">
|
| 185 |
+
<BarChart3 className="h-3.5 w-3.5" /> Evaluation
|
| 186 |
+
</TabsTrigger>
|
| 187 |
+
</TabsList>
|
| 188 |
+
|
| 189 |
+
{/* Overview Tab */}
|
| 190 |
+
<TabsContent value="overview" className="space-y-6">
|
| 191 |
+
<div className="grid md:grid-cols-2 gap-6">
|
| 192 |
+
<DeadlinesTimeline deadlines={[]} /> {/* Partially mocked or need another endpoint */}
|
| 193 |
+
<BudgetCard budget={{
|
| 194 |
+
estimatedBudget: "KES TBD",
|
| 195 |
+
bidSecurity: "TBD",
|
| 196 |
+
lots: "TBD",
|
| 197 |
+
paymentTerms: "TBD",
|
| 198 |
+
currency: "KES"
|
| 199 |
+
}} />
|
| 200 |
+
</div>
|
| 201 |
+
<EligibilitySection items={[]} />
|
| 202 |
+
</TabsContent>
|
| 203 |
+
|
| 204 |
+
{/* Compliance Tab */}
|
| 205 |
+
<TabsContent value="compliance">
|
| 206 |
+
<Card>
|
| 207 |
+
<CardHeader>
|
| 208 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 209 |
+
<Shield className="h-5 w-5 text-primary" /> Compliance Matrix
|
| 210 |
+
</CardTitle>
|
| 211 |
+
</CardHeader>
|
| 212 |
+
<CardContent>
|
| 213 |
+
<div className="space-y-3">
|
| 214 |
+
{analysis.complianceItems?.map((item: any) => (
|
| 215 |
+
<div key={item.id} className="flex items-start gap-4 p-3 rounded-lg hover:bg-muted/30 transition-colors border border-transparent hover:border-border">
|
| 216 |
+
{item.status === "PASS" && <CheckCircle2 className="h-5 w-5 text-success shrink-0 mt-0.5" />}
|
| 217 |
+
{item.status === "FAIL" && <XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />}
|
| 218 |
+
{item.status === "UNKNOWN" && <AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />}
|
| 219 |
+
<div className="min-w-0 flex-1">
|
| 220 |
+
<div className="flex items-center gap-2 mb-1">
|
| 221 |
+
<p className="text-sm font-semibold text-foreground">{item.title}</p>
|
| 222 |
+
<Badge variant="outline" className="text-[10px] uppercase">{item.severity}</Badge>
|
| 223 |
+
</div>
|
| 224 |
+
<p className="text-xs text-muted-foreground mb-2">{item.requirementText}</p>
|
| 225 |
+
{item.remediation && (
|
| 226 |
+
<div className="bg-primary/5 p-2 rounded text-xs text-primary border border-primary/10">
|
| 227 |
+
<span className="font-semibold">Remediation:</span> {item.remediation}
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
))}
|
| 233 |
+
</div>
|
| 234 |
+
</CardContent>
|
| 235 |
+
</Card>
|
| 236 |
+
</TabsContent>
|
| 237 |
+
|
| 238 |
+
{/* Red Flags Tab */}
|
| 239 |
+
<TabsContent value="red-flags">
|
| 240 |
+
<Card>
|
| 241 |
+
<CardHeader>
|
| 242 |
+
<CardTitle className="text-lg flex items-center gap-2">
|
| 243 |
+
<AlertTriangle className="h-5 w-5 text-warning" /> Red Flags & Blockers
|
| 244 |
+
</CardTitle>
|
| 245 |
+
</CardHeader>
|
| 246 |
+
<CardContent>
|
| 247 |
+
<div className="space-y-4">
|
| 248 |
+
{analysis.bidScore?.blockers?.map((blocker: string, i: number) => (
|
| 249 |
+
<Card key={i} className="border-l-4 border-l-destructive">
|
| 250 |
+
<CardContent className="p-4 flex gap-3">
|
| 251 |
+
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
| 252 |
+
<div>
|
| 253 |
+
<h4 className="text-sm font-semibold text-foreground mb-1">Critical Blocker #{i+1}</h4>
|
| 254 |
+
<p className="text-xs text-muted-foreground">{blocker}</p>
|
| 255 |
+
</div>
|
| 256 |
+
</CardContent>
|
| 257 |
+
</Card>
|
| 258 |
+
))}
|
| 259 |
+
{analysis.bidScore?.blockers?.length === 0 && (
|
| 260 |
+
<div className="text-center py-8 text-muted-foreground">
|
| 261 |
+
No critical red flags or blockers identified.
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
</CardContent>
|
| 266 |
+
</Card>
|
| 267 |
+
</TabsContent>
|
| 268 |
+
|
| 269 |
+
{/* Evaluation Tab */}
|
| 270 |
+
<TabsContent value="evaluation">
|
| 271 |
+
<EvaluationCriteria criteria={[]} />
|
| 272 |
+
</TabsContent>
|
| 273 |
+
</Tabs>
|
| 274 |
+
</div>
|
| 275 |
+
);
|
| 276 |
+
}
|
apps/web/src/app/(dashboard)/tenders/page.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useTenders } from "@/hooks/use-tenders";
|
| 5 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
import { Input } from "@/components/ui/input";
|
| 8 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 9 |
+
import { FileText, Search, Clock, ArrowRight, Loader2 } from "lucide-react";
|
| 10 |
+
import Link from "next/link";
|
| 11 |
+
|
| 12 |
+
const statusColors: Record<string, string> = {
|
| 13 |
+
PROCESSING: "bg-warning/10 text-warning border-warning/20",
|
| 14 |
+
ANALYSIS_READY: "bg-primary/10 text-primary border-primary/20",
|
| 15 |
+
FAILED: "bg-destructive/10 text-destructive border-destructive/20",
|
| 16 |
+
UPLOADED: "bg-muted text-muted-foreground border-border",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function getScoreColor(score: number | undefined) {
|
| 20 |
+
if (!score) return "text-muted-foreground";
|
| 21 |
+
if (score >= 70) return "text-success";
|
| 22 |
+
if (score >= 40) return "text-warning";
|
| 23 |
+
return "text-destructive";
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export default function TendersListPage() {
|
| 27 |
+
const [search, setSearch] = useState("");
|
| 28 |
+
const [statusFilter, setStatusFilter] = useState("all");
|
| 29 |
+
|
| 30 |
+
const { data: tenders, isLoading } = useTenders({
|
| 31 |
+
status: statusFilter === "all" ? undefined : statusFilter,
|
| 32 |
+
q: search || undefined
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="space-y-6 animate-fade-in">
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="text-2xl font-bold text-foreground">My Tenders</h1>
|
| 39 |
+
<p className="text-muted-foreground text-sm">Manage and track all your uploaded tenders</p>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{/* Filters */}
|
| 43 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 44 |
+
<div className="relative flex-1">
|
| 45 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
| 46 |
+
<Input
|
| 47 |
+
placeholder="Search tenders..."
|
| 48 |
+
className="pl-9"
|
| 49 |
+
value={search}
|
| 50 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
| 54 |
+
<SelectTrigger className="w-full sm:w-40">
|
| 55 |
+
<SelectValue placeholder="Status" />
|
| 56 |
+
</SelectTrigger>
|
| 57 |
+
<SelectContent>
|
| 58 |
+
<SelectItem value="all">All Status</SelectItem>
|
| 59 |
+
<SelectItem value="PROCESSING">Processing</SelectItem>
|
| 60 |
+
<SelectItem value="ANALYSIS_READY">Ready</SelectItem>
|
| 61 |
+
<SelectItem value="FAILED">Failed</SelectItem>
|
| 62 |
+
</SelectContent>
|
| 63 |
+
</Select>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* Tenders List */}
|
| 67 |
+
<div className="space-y-3">
|
| 68 |
+
{isLoading ? (
|
| 69 |
+
<div className="flex justify-center py-12">
|
| 70 |
+
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
| 71 |
+
</div>
|
| 72 |
+
) : tenders?.length === 0 ? (
|
| 73 |
+
<div className="text-center py-12">
|
| 74 |
+
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
| 75 |
+
<p className="text-muted-foreground">No tenders found matching your criteria.</p>
|
| 76 |
+
</div>
|
| 77 |
+
) : (
|
| 78 |
+
tenders?.map((tender) => (
|
| 79 |
+
<Link key={tender.tenderId} href={`/tenders/${tender.tenderId}`}>
|
| 80 |
+
<Card className="hover:shadow-md transition-all hover:border-primary/20 cursor-pointer">
|
| 81 |
+
<CardContent className="p-4 flex items-center gap-4">
|
| 82 |
+
<div className="hidden sm:flex w-12 h-12 rounded-xl bg-primary/5 items-center justify-center shrink-0">
|
| 83 |
+
<FileText className="h-5 w-5 text-primary" />
|
| 84 |
+
</div>
|
| 85 |
+
<div className="flex-1 min-w-0">
|
| 86 |
+
<p className="text-sm font-medium text-foreground truncate">{tender.fileName}</p>
|
| 87 |
+
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
| 88 |
+
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
| 89 |
+
<Clock className="h-3 w-3" /> {new Date(tender.createdAt).toLocaleDateString()}
|
| 90 |
+
</span>
|
| 91 |
+
{tender.unlocked && (
|
| 92 |
+
<Badge variant="secondary" className="text-[10px] h-4">Unlocked</Badge>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<div className="flex items-center gap-3 shrink-0">
|
| 97 |
+
<span className={`text-lg font-bold ${getScoreColor(tender.score)}`}>
|
| 98 |
+
{tender.score || "--"}
|
| 99 |
+
</span>
|
| 100 |
+
<Badge variant="outline" className={`text-xs ${statusColors[tender.status] || ""}`}>
|
| 101 |
+
{tender.status.replace("_", " ")}
|
| 102 |
+
</Badge>
|
| 103 |
+
<ArrowRight className="h-4 w-4 text-muted-foreground hidden sm:block" />
|
| 104 |
+
</div>
|
| 105 |
+
</CardContent>
|
| 106 |
+
</Card>
|
| 107 |
+
</Link>
|
| 108 |
+
))
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
}
|
apps/web/src/app/(dashboard)/upload/page.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useTenders } from "@/hooks/use-tenders";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { Progress } from "@/components/ui/progress";
|
| 9 |
+
import { Upload, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
| 10 |
+
import { useRouter } from "next/navigation";
|
| 11 |
+
import { toast } from "sonner";
|
| 12 |
+
|
| 13 |
+
interface UploadedFile {
|
| 14 |
+
id: string;
|
| 15 |
+
name: string;
|
| 16 |
+
size: string;
|
| 17 |
+
status: "uploading" | "complete" | "error";
|
| 18 |
+
progress: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function UploadTenderPage() {
|
| 22 |
+
const router = useRouter();
|
| 23 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 24 |
+
const [uploadingFile, setUploadingFile] = useState<UploadedFile | null>(null);
|
| 25 |
+
const { data: history, isLoading: historyLoading } = useTenders({ limit: 5 });
|
| 26 |
+
|
| 27 |
+
const handleFileUpload = async (file: File) => {
|
| 28 |
+
if (file.type !== "application/pdf") {
|
| 29 |
+
toast.error("Please upload a PDF file");
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
setUploadingFile({
|
| 34 |
+
id: "pending",
|
| 35 |
+
name: file.name,
|
| 36 |
+
size: `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
|
| 37 |
+
status: "uploading",
|
| 38 |
+
progress: 0,
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
// 1. Initialize upload session
|
| 43 |
+
const initRes = await fetch("/api/uploads/init", {
|
| 44 |
+
method: "POST",
|
| 45 |
+
body: JSON.stringify({
|
| 46 |
+
fileName: file.name,
|
| 47 |
+
fileSize: file.size,
|
| 48 |
+
contentType: file.type,
|
| 49 |
+
}),
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (!initRes.ok) throw new Error("Failed to initialize upload");
|
| 53 |
+
const { data: session } = await initRes.json();
|
| 54 |
+
|
| 55 |
+
// 2. Upload to Supabase Storage
|
| 56 |
+
const uploadRes = await fetch(session.uploadUrl, {
|
| 57 |
+
method: "PUT",
|
| 58 |
+
headers: {
|
| 59 |
+
"Authorization": `Bearer ${session.uploadToken}`,
|
| 60 |
+
"Content-Type": file.type,
|
| 61 |
+
},
|
| 62 |
+
body: file,
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
if (!uploadRes.ok) throw new Error("Upload to storage failed");
|
| 66 |
+
setUploadingFile(prev => prev ? { ...prev, progress: 90 } : null);
|
| 67 |
+
|
| 68 |
+
// 3. Trigger processing
|
| 69 |
+
const processRes = await fetch(`/api/tenders/${session.tenderId}/process`, {
|
| 70 |
+
method: "POST",
|
| 71 |
+
body: JSON.stringify({ forceReprocess: false }),
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!processRes.ok) throw new Error("Failed to start analysis");
|
| 75 |
+
|
| 76 |
+
setUploadingFile(prev => prev ? { ...prev, progress: 100, status: "complete" } : null);
|
| 77 |
+
toast.success("Upload successful! Redirecting to analysis...");
|
| 78 |
+
|
| 79 |
+
// Redirect to the tender detail page after a short delay
|
| 80 |
+
setTimeout(() => {
|
| 81 |
+
router.push(`/tenders/${session.tenderId}`);
|
| 82 |
+
}, 1500);
|
| 83 |
+
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error(error);
|
| 86 |
+
setUploadingFile(prev => prev ? { ...prev, status: "error" } : null);
|
| 87 |
+
toast.error(error instanceof Error ? error.message : "Upload failed");
|
| 88 |
+
}
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const onFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 92 |
+
if (e.target.files?.[0]) {
|
| 93 |
+
handleFileUpload(e.target.files[0]);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div className="space-y-6 animate-fade-in">
|
| 99 |
+
<div>
|
| 100 |
+
<h1 className="text-2xl font-bold text-foreground">Upload Tender</h1>
|
| 101 |
+
<p className="text-muted-foreground text-sm">Upload a tender PDF for AI-powered analysis</p>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{/* Drop Zone */}
|
| 105 |
+
<Card
|
| 106 |
+
className={`border-2 border-dashed transition-colors cursor-pointer relative ${
|
| 107 |
+
isDragging ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
| 108 |
+
}`}
|
| 109 |
+
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
| 110 |
+
onDragLeave={() => setIsDragging(false)}
|
| 111 |
+
onDrop={(e) => {
|
| 112 |
+
e.preventDefault();
|
| 113 |
+
setIsDragging(false);
|
| 114 |
+
const file = e.dataTransfer.files[0];
|
| 115 |
+
if (file) handleFileUpload(file);
|
| 116 |
+
}}
|
| 117 |
+
onClick={() => document.getElementById("file-input")?.click()}
|
| 118 |
+
>
|
| 119 |
+
<CardContent className="py-16 flex flex-col items-center text-center">
|
| 120 |
+
<input
|
| 121 |
+
id="file-input"
|
| 122 |
+
type="file"
|
| 123 |
+
accept=".pdf"
|
| 124 |
+
className="hidden"
|
| 125 |
+
onChange={onFileSelect}
|
| 126 |
+
/>
|
| 127 |
+
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
| 128 |
+
<Upload className="h-8 w-8 text-primary" />
|
| 129 |
+
</div>
|
| 130 |
+
<h3 className="text-lg font-semibold text-foreground mb-1">
|
| 131 |
+
{isDragging ? "Drop your file here" : "Drag & drop your tender PDF"}
|
| 132 |
+
</h3>
|
| 133 |
+
<p className="text-sm text-muted-foreground mb-4">
|
| 134 |
+
or click to browse. Supports PDF files up to 50MB.
|
| 135 |
+
</p>
|
| 136 |
+
<Button variant="outline" size="sm">Browse Files</Button>
|
| 137 |
+
</CardContent>
|
| 138 |
+
</Card>
|
| 139 |
+
|
| 140 |
+
{/* Upload Info */}
|
| 141 |
+
<div className="flex flex-wrap gap-3">
|
| 142 |
+
<Badge variant="secondary" className="gap-1">
|
| 143 |
+
<CheckCircle2 className="h-3 w-3" /> PDF format only
|
| 144 |
+
</Badge>
|
| 145 |
+
<Badge variant="secondary" className="gap-1">
|
| 146 |
+
<CheckCircle2 className="h-3 w-3" /> Max 50MB
|
| 147 |
+
</Badge>
|
| 148 |
+
<Badge variant="secondary" className="gap-1">
|
| 149 |
+
<CheckCircle2 className="h-3 w-3" /> Scanned docs supported (OCR)
|
| 150 |
+
</Badge>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
{/* Active Upload */}
|
| 154 |
+
{uploadingFile && (
|
| 155 |
+
<Card>
|
| 156 |
+
<CardHeader>
|
| 157 |
+
<CardTitle className="text-lg">Current Upload</CardTitle>
|
| 158 |
+
</CardHeader>
|
| 159 |
+
<CardContent>
|
| 160 |
+
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
| 161 |
+
<FileText className="h-5 w-5 text-primary shrink-0" />
|
| 162 |
+
<div className="flex-1 min-w-0">
|
| 163 |
+
<p className="text-sm font-medium text-foreground truncate">{uploadingFile.name}</p>
|
| 164 |
+
<p className="text-xs text-muted-foreground">{uploadingFile.size}</p>
|
| 165 |
+
{uploadingFile.status === "uploading" && (
|
| 166 |
+
<Progress value={uploadingFile.progress} className="mt-2 h-1.5" />
|
| 167 |
+
)}
|
| 168 |
+
</div>
|
| 169 |
+
<div className="shrink-0">
|
| 170 |
+
{uploadingFile.status === "complete" && (
|
| 171 |
+
<CheckCircle2 className="h-5 w-5 text-success" />
|
| 172 |
+
)}
|
| 173 |
+
{uploadingFile.status === "uploading" && (
|
| 174 |
+
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
| 175 |
+
)}
|
| 176 |
+
{uploadingFile.status === "error" && (
|
| 177 |
+
<AlertCircle className="h-5 w-5 text-destructive" />
|
| 178 |
+
)}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</CardContent>
|
| 182 |
+
</Card>
|
| 183 |
+
)}
|
| 184 |
+
|
| 185 |
+
{/* Recent Uploads */}
|
| 186 |
+
<Card>
|
| 187 |
+
<CardHeader>
|
| 188 |
+
<CardTitle className="text-lg">Recent Uploads</CardTitle>
|
| 189 |
+
</CardHeader>
|
| 190 |
+
<CardContent>
|
| 191 |
+
<div className="space-y-3">
|
| 192 |
+
{historyLoading ? (
|
| 193 |
+
<div className="flex justify-center py-4">
|
| 194 |
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 195 |
+
</div>
|
| 196 |
+
) : history?.length === 0 ? (
|
| 197 |
+
<p className="text-sm text-muted-foreground text-center py-4">No recent uploads found.</p>
|
| 198 |
+
) : (
|
| 199 |
+
history?.map((tender) => (
|
| 200 |
+
<Link key={tender.tenderId} href={`/tenders/${tender.tenderId}`}>
|
| 201 |
+
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer">
|
| 202 |
+
<FileText className="h-5 w-5 text-primary shrink-0" />
|
| 203 |
+
<div className="flex-1 min-w-0">
|
| 204 |
+
<p className="text-sm font-medium text-foreground truncate">{tender.fileName}</p>
|
| 205 |
+
<p className="text-xs text-muted-foreground">{new Date(tender.createdAt).toLocaleDateString()}</p>
|
| 206 |
+
</div>
|
| 207 |
+
<Badge variant="outline" className="text-[10px]">{tender.status}</Badge>
|
| 208 |
+
</div>
|
| 209 |
+
</Link>
|
| 210 |
+
))
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
</CardContent>
|
| 214 |
+
</Card>
|
| 215 |
+
</div>
|
| 216 |
+
);
|
| 217 |
+
}
|
apps/web/src/app/admin/layout.tsx
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const metadata = {
|
| 2 |
+
title: "Admin Panel | TenderHub Kenya",
|
| 3 |
+
description: "Superuser administration panel",
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export default function AdminLayout({
|
| 7 |
+
children,
|
| 8 |
+
}: {
|
| 9 |
+
children: React.ReactNode;
|
| 10 |
+
}) {
|
| 11 |
+
return (
|
| 12 |
+
<>
|
| 13 |
+
<style>{`
|
| 14 |
+
.admin-container {
|
| 15 |
+
min-height: 100vh;
|
| 16 |
+
background: #0f172a;
|
| 17 |
+
color: #e2e8f0;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.admin-header {
|
| 21 |
+
display: flex;
|
| 22 |
+
justify-content: space-between;
|
| 23 |
+
align-items: center;
|
| 24 |
+
padding: 1rem 2rem;
|
| 25 |
+
background: #1e293b;
|
| 26 |
+
border-bottom: 1px solid #334155;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.admin-brand {
|
| 30 |
+
display: flex;
|
| 31 |
+
align-items: center;
|
| 32 |
+
gap: 1rem;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.admin-brand h1 {
|
| 36 |
+
margin: 0;
|
| 37 |
+
font-size: 1.5rem;
|
| 38 |
+
font-weight: 600;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.admin-badge {
|
| 42 |
+
background: #dc2626;
|
| 43 |
+
color: white;
|
| 44 |
+
padding: 0.25rem 0.75rem;
|
| 45 |
+
border-radius: 9999px;
|
| 46 |
+
font-size: 0.75rem;
|
| 47 |
+
font-weight: 600;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.admin-nav {
|
| 51 |
+
display: flex;
|
| 52 |
+
gap: 0.5rem;
|
| 53 |
+
align-items: center;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.admin-nav button {
|
| 57 |
+
padding: 0.5rem 1rem;
|
| 58 |
+
border-radius: 0.375rem;
|
| 59 |
+
border: none;
|
| 60 |
+
background: transparent;
|
| 61 |
+
color: #94a3b8;
|
| 62 |
+
cursor: pointer;
|
| 63 |
+
font-size: 0.875rem;
|
| 64 |
+
transition: all 0.2s;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.admin-nav button:hover {
|
| 68 |
+
background: #334155;
|
| 69 |
+
color: #e2e8f0;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.admin-nav button.active {
|
| 73 |
+
background: #3b82f6;
|
| 74 |
+
color: white;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.admin-main {
|
| 78 |
+
padding: 2rem;
|
| 79 |
+
max-width: 1400px;
|
| 80 |
+
margin: 0 auto;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.admin-loading,
|
| 84 |
+
.admin-unauthorized {
|
| 85 |
+
min-height: 100vh;
|
| 86 |
+
display: flex;
|
| 87 |
+
flex-direction: column;
|
| 88 |
+
align-items: center;
|
| 89 |
+
justify-content: center;
|
| 90 |
+
gap: 1rem;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.admin-error {
|
| 94 |
+
background: #dc2626;
|
| 95 |
+
color: white;
|
| 96 |
+
padding: 1rem;
|
| 97 |
+
border-radius: 0.5rem;
|
| 98 |
+
margin-bottom: 1rem;
|
| 99 |
+
display: flex;
|
| 100 |
+
justify-content: space-between;
|
| 101 |
+
align-items: center;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.admin-error button {
|
| 105 |
+
background: white;
|
| 106 |
+
color: #dc2626;
|
| 107 |
+
border: none;
|
| 108 |
+
padding: 0.25rem 0.75rem;
|
| 109 |
+
border-radius: 0.25rem;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.stats-grid {
|
| 114 |
+
display: grid;
|
| 115 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 116 |
+
gap: 1.5rem;
|
| 117 |
+
margin-bottom: 2rem;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.stat-card {
|
| 121 |
+
background: #1e293b;
|
| 122 |
+
border: 1px solid #334155;
|
| 123 |
+
border-radius: 0.5rem;
|
| 124 |
+
padding: 1.5rem;
|
| 125 |
+
text-align: center;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.stat-value {
|
| 129 |
+
font-size: 2rem;
|
| 130 |
+
font-weight: 700;
|
| 131 |
+
color: #3b82f6;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.stat-label {
|
| 135 |
+
font-size: 0.875rem;
|
| 136 |
+
color: #94a3b8;
|
| 137 |
+
margin-top: 0.5rem;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.admin-table {
|
| 141 |
+
width: 100%;
|
| 142 |
+
border-collapse: collapse;
|
| 143 |
+
background: #1e293b;
|
| 144 |
+
border-radius: 0.5rem;
|
| 145 |
+
overflow: hidden;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.admin-table th,
|
| 149 |
+
.admin-table td {
|
| 150 |
+
padding: 1rem;
|
| 151 |
+
text-align: left;
|
| 152 |
+
border-bottom: 1px solid #334155;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.admin-table th {
|
| 156 |
+
background: #0f172a;
|
| 157 |
+
font-weight: 600;
|
| 158 |
+
font-size: 0.875rem;
|
| 159 |
+
color: #94a3b8;
|
| 160 |
+
text-transform: uppercase;
|
| 161 |
+
letter-spacing: 0.05em;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.admin-table tbody tr:hover {
|
| 165 |
+
background: #334155;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.badge-superuser {
|
| 169 |
+
background: #dc2626;
|
| 170 |
+
color: white;
|
| 171 |
+
padding: 0.25rem 0.5rem;
|
| 172 |
+
border-radius: 0.25rem;
|
| 173 |
+
font-size: 0.75rem;
|
| 174 |
+
font-weight: 600;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.badge-user {
|
| 178 |
+
background: #334155;
|
| 179 |
+
color: #94a3b8;
|
| 180 |
+
padding: 0.25rem 0.5rem;
|
| 181 |
+
border-radius: 0.25rem;
|
| 182 |
+
font-size: 0.75rem;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.btn-sm {
|
| 186 |
+
padding: 0.25rem 0.75rem;
|
| 187 |
+
border-radius: 0.25rem;
|
| 188 |
+
border: none;
|
| 189 |
+
background: #3b82f6;
|
| 190 |
+
color: white;
|
| 191 |
+
cursor: pointer;
|
| 192 |
+
font-size: 0.75rem;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.btn-sm:hover {
|
| 196 |
+
background: #2563eb;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.quick-actions {
|
| 200 |
+
display: flex;
|
| 201 |
+
gap: 1rem;
|
| 202 |
+
margin-top: 1rem;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.quick-actions button {
|
| 206 |
+
padding: 0.75rem 1.5rem;
|
| 207 |
+
border-radius: 0.375rem;
|
| 208 |
+
border: none;
|
| 209 |
+
background: #3b82f6;
|
| 210 |
+
color: white;
|
| 211 |
+
cursor: pointer;
|
| 212 |
+
font-weight: 500;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.quick-actions button:hover {
|
| 216 |
+
background: #2563eb;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.spinner {
|
| 220 |
+
width: 40px;
|
| 221 |
+
height: 40px;
|
| 222 |
+
border: 4px solid #334155;
|
| 223 |
+
border-top-color: #3b82f6;
|
| 224 |
+
border-radius: 50%;
|
| 225 |
+
animation: spin 1s linear infinite;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
@keyframes spin {
|
| 229 |
+
to { transform: rotate(360deg); }
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
h2 {
|
| 233 |
+
margin-bottom: 1.5rem;
|
| 234 |
+
font-size: 1.25rem;
|
| 235 |
+
font-weight: 600;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
h3 {
|
| 239 |
+
margin-top: 2rem;
|
| 240 |
+
margin-bottom: 1rem;
|
| 241 |
+
font-size: 1rem;
|
| 242 |
+
font-weight: 600;
|
| 243 |
+
color: #94a3b8;
|
| 244 |
+
}
|
| 245 |
+
`}</style>
|
| 246 |
+
{children}
|
| 247 |
+
</>
|
| 248 |
+
);
|
| 249 |
+
}
|
apps/web/src/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { getSupabaseBrowser } from "@/lib/supabase/browser";
|
| 6 |
+
|
| 7 |
+
type Stats = {
|
| 8 |
+
totalUsers: number;
|
| 9 |
+
totalOrgs: number;
|
| 10 |
+
totalTenders: number;
|
| 11 |
+
totalPayments: number;
|
| 12 |
+
recentLogins: number;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
type RecentActivity = {
|
| 16 |
+
id: string;
|
| 17 |
+
action: string;
|
| 18 |
+
resourceType: string;
|
| 19 |
+
resourceId: string | null;
|
| 20 |
+
createdAt: string;
|
| 21 |
+
adminEmail?: string;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
type UserWithOrg = {
|
| 25 |
+
userId: string;
|
| 26 |
+
email: string;
|
| 27 |
+
orgName: string;
|
| 28 |
+
orgSlug: string;
|
| 29 |
+
isSuperuser: boolean;
|
| 30 |
+
createdAt: string;
|
| 31 |
+
lastSignIn: string | null;
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
type FailedJob = {
|
| 35 |
+
id: string;
|
| 36 |
+
tender_id: string;
|
| 37 |
+
status: string;
|
| 38 |
+
last_error: string;
|
| 39 |
+
attempt_count: number;
|
| 40 |
+
created_at: string;
|
| 41 |
+
tenders: { source_filename: string; organization_id: string } | null;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
type ReviewQueueItem = {
|
| 45 |
+
tenderId: string;
|
| 46 |
+
fileName: string;
|
| 47 |
+
status: string;
|
| 48 |
+
unlocked: boolean;
|
| 49 |
+
updatedAt: string;
|
| 50 |
+
decision: "BID" | "NO_BID" | "REVIEW" | null;
|
| 51 |
+
confidence: number | null;
|
| 52 |
+
blockerCount: number;
|
| 53 |
+
criticalFailCount: number;
|
| 54 |
+
highFailCount: number;
|
| 55 |
+
riskScore: number;
|
| 56 |
+
latestFailureReason: string | null;
|
| 57 |
+
reasonCodes: string[];
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
type ReviewQueueSummary = {
|
| 61 |
+
total: number;
|
| 62 |
+
requiresAction: number;
|
| 63 |
+
failed: number;
|
| 64 |
+
reviewDecision: number;
|
| 65 |
+
lowConfidence: number;
|
| 66 |
+
hardBlockers: number;
|
| 67 |
+
criticalCompliance: number;
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
type ReviewActionType = "ACKNOWLEDGE" | "REQUEST_REPROCESS" | "APPROVE_FOR_DRAFT" | "ESCALATE";
|
| 71 |
+
|
| 72 |
+
export default function AdminPage() {
|
| 73 |
+
const router = useRouter();
|
| 74 |
+
const [loading, setLoading] = useState(true);
|
| 75 |
+
const [isAdmin, setIsAdmin] = useState(false);
|
| 76 |
+
const [stats, setStats] = useState<Stats | null>(null);
|
| 77 |
+
const [users, setUsers] = useState<UserWithOrg[]>([]);
|
| 78 |
+
const [activity, setActivity] = useState<RecentActivity[]>([]);
|
| 79 |
+
const [failedJobs, setFailedJobs] = useState<FailedJob[]>([]);
|
| 80 |
+
const [activeTab, setActiveTab] = useState<"overview" | "users" | "interventions" | "review" | "activity">("overview");
|
| 81 |
+
const [reviewItems, setReviewItems] = useState<ReviewQueueItem[]>([]);
|
| 82 |
+
const [reviewSummary, setReviewSummary] = useState<ReviewQueueSummary | null>(null);
|
| 83 |
+
const [reviewLoading, setReviewLoading] = useState(false);
|
| 84 |
+
const [reviewFilters, setReviewFilters] = useState({
|
| 85 |
+
minConfidence: "0.70",
|
| 86 |
+
requireActionOnly: true,
|
| 87 |
+
reasonFilter: "all" as "all" | ReviewQueueItem["reasonCodes"][number],
|
| 88 |
+
sort: "RISK_DESC" as "RISK_DESC" | "UPDATED_DESC" | "CONFIDENCE_ASC"
|
| 89 |
+
});
|
| 90 |
+
const [reviewActionByTender, setReviewActionByTender] = useState<Record<string, ReviewActionType>>({});
|
| 91 |
+
const [reviewNoteByTender, setReviewNoteByTender] = useState<Record<string, string>>({});
|
| 92 |
+
const [error, setError] = useState<string | null>(null);
|
| 93 |
+
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
checkAdminAccess();
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
+
async function checkAdminAccess() {
|
| 99 |
+
try {
|
| 100 |
+
const supabase = getSupabaseBrowser();
|
| 101 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 102 |
+
if (!user) {
|
| 103 |
+
router.push("/login?redirect=/admin");
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const response = await fetch("/api/admin/check", {
|
| 108 |
+
headers: { "content-type": "application/json" },
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
if (!response.ok) {
|
| 112 |
+
router.push("/dashboard");
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
setIsAdmin(true);
|
| 117 |
+
loadData();
|
| 118 |
+
} catch (err) {
|
| 119 |
+
setError("Failed to verify admin access");
|
| 120 |
+
setLoading(false);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function loadData() {
|
| 125 |
+
setLoading(true);
|
| 126 |
+
try {
|
| 127 |
+
const [statsRes, usersRes, activityRes, jobsRes] = await Promise.all([
|
| 128 |
+
fetch("/api/admin/stats"),
|
| 129 |
+
fetch("/api/admin/users"),
|
| 130 |
+
fetch("/api/admin/activity"),
|
| 131 |
+
fetch("/api/admin/jobs"),
|
| 132 |
+
]);
|
| 133 |
+
|
| 134 |
+
if (statsRes.ok) {
|
| 135 |
+
const statsData = await statsRes.json();
|
| 136 |
+
if (statsData.ok) setStats(statsData.data);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (usersRes.ok) {
|
| 140 |
+
const usersData = await usersRes.json();
|
| 141 |
+
if (usersData.ok) setUsers(usersData.data.users);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (activityRes.ok) {
|
| 145 |
+
const activityData = await activityRes.json();
|
| 146 |
+
if (activityData.ok) setActivity(activityData.data.activity);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
if (jobsRes.ok) {
|
| 150 |
+
const jobsData = await jobsRes.json();
|
| 151 |
+
if (jobsData.ok) setFailedJobs(jobsData.data.failedJobs);
|
| 152 |
+
}
|
| 153 |
+
} catch (err) {
|
| 154 |
+
setError("Failed to load admin data");
|
| 155 |
+
} finally {
|
| 156 |
+
setLoading(false);
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function toggleSuperuser(userId: string, currentValue: boolean) {
|
| 161 |
+
try {
|
| 162 |
+
const response = await fetch("/api/admin/users/toggle-superuser", {
|
| 163 |
+
method: "POST",
|
| 164 |
+
headers: { "content-type": "application/json" },
|
| 165 |
+
body: JSON.stringify({ userId, isSuperuser: !currentValue }),
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
if (response.ok) {
|
| 169 |
+
setUsers(users.map(u =>
|
| 170 |
+
u.userId === userId ? { ...u, isSuperuser: !currentValue } : u
|
| 171 |
+
));
|
| 172 |
+
}
|
| 173 |
+
} catch (err) {
|
| 174 |
+
setError("Failed to update user");
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
async function retryJob(jobId: string, tenderId: string) {
|
| 179 |
+
try {
|
| 180 |
+
const res = await fetch("/api/admin/jobs/retry", {
|
| 181 |
+
method: "POST",
|
| 182 |
+
headers: { "content-type": "application/json" },
|
| 183 |
+
body: JSON.stringify({ jobId, tenderId }),
|
| 184 |
+
});
|
| 185 |
+
if (res.ok) {
|
| 186 |
+
setFailedJobs(failedJobs.filter(j => j.id !== jobId));
|
| 187 |
+
} else {
|
| 188 |
+
setError("Failed to retry job");
|
| 189 |
+
}
|
| 190 |
+
} catch (err) {
|
| 191 |
+
setError("An error occurred trying to retry");
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async function loadReviewQueue() {
|
| 196 |
+
setReviewLoading(true);
|
| 197 |
+
try {
|
| 198 |
+
const params = new URLSearchParams({
|
| 199 |
+
limit: "20",
|
| 200 |
+
minConfidence: reviewFilters.minConfidence,
|
| 201 |
+
requireAction: reviewFilters.requireActionOnly ? "true" : "false",
|
| 202 |
+
sort: reviewFilters.sort,
|
| 203 |
+
});
|
| 204 |
+
if (reviewFilters.reasonFilter !== "all") {
|
| 205 |
+
params.set("reason", reviewFilters.reasonFilter);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const res = await fetch(`/api/tenders/review-queue?${params.toString()}`);
|
| 209 |
+
const data = await res.json();
|
| 210 |
+
if (data.ok) {
|
| 211 |
+
setReviewItems(data.data.items || []);
|
| 212 |
+
setReviewSummary(data.data.summary || null);
|
| 213 |
+
} else {
|
| 214 |
+
setError("Failed to load review queue");
|
| 215 |
+
}
|
| 216 |
+
} catch {
|
| 217 |
+
setError("Failed to load review queue");
|
| 218 |
+
} finally {
|
| 219 |
+
setReviewLoading(false);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async function submitReviewAction(tenderId: string) {
|
| 224 |
+
const action = reviewActionByTender[tenderId] ?? "ACKNOWLEDGE";
|
| 225 |
+
const note = (reviewNoteByTender[tenderId] ?? "").trim();
|
| 226 |
+
|
| 227 |
+
try {
|
| 228 |
+
const res = await fetch(`/api/tenders/${tenderId}/review-action`, {
|
| 229 |
+
method: "POST",
|
| 230 |
+
headers: { "content-type": "application/json" },
|
| 231 |
+
body: JSON.stringify({
|
| 232 |
+
action,
|
| 233 |
+
note: note.length > 0 ? note : undefined,
|
| 234 |
+
}),
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
if (res.ok) {
|
| 238 |
+
await loadReviewQueue();
|
| 239 |
+
} else {
|
| 240 |
+
setError("Failed to submit review action");
|
| 241 |
+
}
|
| 242 |
+
} catch {
|
| 243 |
+
setError("Failed to submit review action");
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
if (loading) {
|
| 248 |
+
return (
|
| 249 |
+
<div className="admin-loading">
|
| 250 |
+
<div className="spinner"></div>
|
| 251 |
+
<p>Loading admin panel...</p>
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if (!isAdmin) {
|
| 257 |
+
return (
|
| 258 |
+
<div className="admin-unauthorized">
|
| 259 |
+
<h1>Unauthorized</h1>
|
| 260 |
+
<p>You do not have permission to access this page.</p>
|
| 261 |
+
<button onClick={() => router.push("/dashboard")}>
|
| 262 |
+
Go to Dashboard
|
| 263 |
+
</button>
|
| 264 |
+
</div>
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
return (
|
| 269 |
+
<div className="admin-container">
|
| 270 |
+
<header className="admin-header">
|
| 271 |
+
<div className="admin-brand">
|
| 272 |
+
<span className="admin-badge">SUPERUSER</span>
|
| 273 |
+
<h1>Admin Panel</h1>
|
| 274 |
+
</div>
|
| 275 |
+
<nav className="admin-nav">
|
| 276 |
+
<button
|
| 277 |
+
className={activeTab === "overview" ? "active" : ""}
|
| 278 |
+
onClick={() => setActiveTab("overview")}
|
| 279 |
+
>
|
| 280 |
+
Overview
|
| 281 |
+
</button>
|
| 282 |
+
<button
|
| 283 |
+
className={activeTab === "users" ? "active" : ""}
|
| 284 |
+
onClick={() => setActiveTab("users")}
|
| 285 |
+
>
|
| 286 |
+
Users
|
| 287 |
+
</button>
|
| 288 |
+
<button
|
| 289 |
+
className={activeTab === "interventions" ? "active" : ""}
|
| 290 |
+
onClick={() => setActiveTab("interventions")}
|
| 291 |
+
>
|
| 292 |
+
Interventions
|
| 293 |
+
</button>
|
| 294 |
+
<button
|
| 295 |
+
className={activeTab === "review" ? "active" : ""}
|
| 296 |
+
onClick={() => {
|
| 297 |
+
setActiveTab("review");
|
| 298 |
+
void loadReviewQueue();
|
| 299 |
+
}}
|
| 300 |
+
>
|
| 301 |
+
Review Queue
|
| 302 |
+
</button>
|
| 303 |
+
<button
|
| 304 |
+
className={activeTab === "activity" ? "active" : ""}
|
| 305 |
+
onClick={() => setActiveTab("activity")}
|
| 306 |
+
>
|
| 307 |
+
Audit Log
|
| 308 |
+
</button>
|
| 309 |
+
<button onClick={() => getSupabaseBrowser().auth.signOut()}>
|
| 310 |
+
Sign Out
|
| 311 |
+
</button>
|
| 312 |
+
</nav>
|
| 313 |
+
</header>
|
| 314 |
+
|
| 315 |
+
<main className="admin-main">
|
| 316 |
+
{error && (
|
| 317 |
+
<div className="admin-error">
|
| 318 |
+
{error}
|
| 319 |
+
<button onClick={() => setError(null)}>Dismiss</button>
|
| 320 |
+
</div>
|
| 321 |
+
)}
|
| 322 |
+
|
| 323 |
+
{activeTab === "overview" && stats && (
|
| 324 |
+
<section className="admin-overview">
|
| 325 |
+
<h2>Platform Overview</h2>
|
| 326 |
+
<div className="stats-grid">
|
| 327 |
+
<div className="stat-card">
|
| 328 |
+
<div className="stat-value">{stats.totalUsers}</div>
|
| 329 |
+
<div className="stat-label">Total Users</div>
|
| 330 |
+
</div>
|
| 331 |
+
<div className="stat-card">
|
| 332 |
+
<div className="stat-value">{stats.totalOrgs}</div>
|
| 333 |
+
<div className="stat-label">Organizations</div>
|
| 334 |
+
</div>
|
| 335 |
+
<div className="stat-card">
|
| 336 |
+
<div className="stat-value">{stats.totalTenders}</div>
|
| 337 |
+
<div className="stat-label">Tenders</div>
|
| 338 |
+
</div>
|
| 339 |
+
<div className="stat-card">
|
| 340 |
+
<div className="stat-value">{stats.totalPayments}</div>
|
| 341 |
+
<div className="stat-label">Payments</div>
|
| 342 |
+
</div>
|
| 343 |
+
<div className="stat-card">
|
| 344 |
+
<div className="stat-value">{stats.recentLogins}</div>
|
| 345 |
+
<div className="stat-label">Logins (24h)</div>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<h3>Quick Actions</h3>
|
| 350 |
+
<div className="quick-actions">
|
| 351 |
+
<button onClick={() => loadData()}>
|
| 352 |
+
Refresh Data
|
| 353 |
+
</button>
|
| 354 |
+
</div>
|
| 355 |
+
</section>
|
| 356 |
+
)}
|
| 357 |
+
|
| 358 |
+
{activeTab === "users" && (
|
| 359 |
+
<section className="admin-users">
|
| 360 |
+
<h2>User Management</h2>
|
| 361 |
+
<table className="admin-table">
|
| 362 |
+
<thead>
|
| 363 |
+
<tr>
|
| 364 |
+
<th>Email</th>
|
| 365 |
+
<th>Organization</th>
|
| 366 |
+
<th>Created</th>
|
| 367 |
+
<th>Last Sign In</th>
|
| 368 |
+
<th>Superuser</th>
|
| 369 |
+
<th>Actions</th>
|
| 370 |
+
</tr>
|
| 371 |
+
</thead>
|
| 372 |
+
<tbody>
|
| 373 |
+
{users.map(user => (
|
| 374 |
+
<tr key={user.userId}>
|
| 375 |
+
<td>{user.email}</td>
|
| 376 |
+
<td>{user.orgName} ({user.orgSlug})</td>
|
| 377 |
+
<td>{new Date(user.createdAt).toLocaleDateString()}</td>
|
| 378 |
+
<td>{user.lastSignIn ? new Date(user.lastSignIn).toLocaleDateString() : "Never"}</td>
|
| 379 |
+
<td>
|
| 380 |
+
<span className={user.isSuperuser ? "badge-superuser" : "badge-user"}>
|
| 381 |
+
{user.isSuperuser ? "Yes" : "No"}
|
| 382 |
+
</span>
|
| 383 |
+
</td>
|
| 384 |
+
<td>
|
| 385 |
+
<button
|
| 386 |
+
onClick={() => toggleSuperuser(user.userId, user.isSuperuser)}
|
| 387 |
+
className="btn-sm"
|
| 388 |
+
>
|
| 389 |
+
{user.isSuperuser ? "Remove Superuser" : "Make Superuser"}
|
| 390 |
+
</button>
|
| 391 |
+
</td>
|
| 392 |
+
</tr>
|
| 393 |
+
))}
|
| 394 |
+
</tbody>
|
| 395 |
+
</table>
|
| 396 |
+
</section>
|
| 397 |
+
)}
|
| 398 |
+
|
| 399 |
+
{activeTab === "interventions" && (
|
| 400 |
+
<section className="admin-users">
|
| 401 |
+
<h2>Tender Interventions (Failed Jobs)</h2>
|
| 402 |
+
{failedJobs.length === 0 ? (
|
| 403 |
+
<p>No failed jobs found. Everything is running smoothly!</p>
|
| 404 |
+
) : (
|
| 405 |
+
<table className="admin-table">
|
| 406 |
+
<thead>
|
| 407 |
+
<tr>
|
| 408 |
+
<th>Tender File</th>
|
| 409 |
+
<th>Attempts</th>
|
| 410 |
+
<th>Last Error</th>
|
| 411 |
+
<th>Failed At</th>
|
| 412 |
+
<th>Actions</th>
|
| 413 |
+
</tr>
|
| 414 |
+
</thead>
|
| 415 |
+
<tbody>
|
| 416 |
+
{failedJobs.map(job => (
|
| 417 |
+
<tr key={job.id}>
|
| 418 |
+
<td>{job.tenders?.source_filename || job.tender_id}</td>
|
| 419 |
+
<td>{job.attempt_count}</td>
|
| 420 |
+
<td style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
| 421 |
+
{job.last_error || "Unknown Error"}
|
| 422 |
+
</td>
|
| 423 |
+
<td>{new Date(job.created_at).toLocaleString()}</td>
|
| 424 |
+
<td>
|
| 425 |
+
<button
|
| 426 |
+
onClick={() => retryJob(job.id, job.tender_id)}
|
| 427 |
+
className="btn-sm"
|
| 428 |
+
>
|
| 429 |
+
Retry Job
|
| 430 |
+
</button>
|
| 431 |
+
</td>
|
| 432 |
+
</tr>
|
| 433 |
+
))}
|
| 434 |
+
</tbody>
|
| 435 |
+
</table>
|
| 436 |
+
)}
|
| 437 |
+
</section>
|
| 438 |
+
)}
|
| 439 |
+
|
| 440 |
+
{activeTab === "review" && (
|
| 441 |
+
<section className="admin-review">
|
| 442 |
+
<h2>Review Queue</h2>
|
| 443 |
+
{reviewSummary && (
|
| 444 |
+
<div className="stats-grid" style={{ marginBottom: "1.5rem" }}>
|
| 445 |
+
<div className="stat-card">
|
| 446 |
+
<div className="stat-value">{reviewSummary.total}</div>
|
| 447 |
+
<div className="stat-label">Total</div>
|
| 448 |
+
</div>
|
| 449 |
+
<div className="stat-card">
|
| 450 |
+
<div className="stat-value">{reviewSummary.requiresAction}</div>
|
| 451 |
+
<div className="stat-label">Needs Action</div>
|
| 452 |
+
</div>
|
| 453 |
+
<div className="stat-card">
|
| 454 |
+
<div className="stat-value">{reviewSummary.failed}</div>
|
| 455 |
+
<div className="stat-label">Failed</div>
|
| 456 |
+
</div>
|
| 457 |
+
<div className="stat-card">
|
| 458 |
+
<div className="stat-value">{reviewSummary.lowConfidence}</div>
|
| 459 |
+
<div className="stat-label">Low Confidence</div>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
)}
|
| 463 |
+
|
| 464 |
+
<div className="review-filters" style={{ display: "flex", gap: "1rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
| 465 |
+
<label>
|
| 466 |
+
Min Confidence
|
| 467 |
+
<input
|
| 468 |
+
type="number"
|
| 469 |
+
step="0.05"
|
| 470 |
+
min="0"
|
| 471 |
+
max="1"
|
| 472 |
+
value={reviewFilters.minConfidence}
|
| 473 |
+
onChange={(e) => setReviewFilters(f => ({ ...f, minConfidence: e.target.value }))}
|
| 474 |
+
style={{ marginLeft: "0.5rem", width: "80px" }}
|
| 475 |
+
/>
|
| 476 |
+
</label>
|
| 477 |
+
<label>
|
| 478 |
+
<input
|
| 479 |
+
type="checkbox"
|
| 480 |
+
checked={reviewFilters.requireActionOnly}
|
| 481 |
+
onChange={(e) => setReviewFilters(f => ({ ...f, requireActionOnly: e.target.checked }))}
|
| 482 |
+
/>
|
| 483 |
+
Action Required Only
|
| 484 |
+
</label>
|
| 485 |
+
<select
|
| 486 |
+
value={reviewFilters.sort}
|
| 487 |
+
onChange={(e) => setReviewFilters(f => ({ ...f, sort: e.target.value as typeof reviewFilters.sort }))}
|
| 488 |
+
>
|
| 489 |
+
<option value="RISK_DESC">Risk (High to Low)</option>
|
| 490 |
+
<option value="UPDATED_DESC">Updated (Newest)</option>
|
| 491 |
+
<option value="CONFIDENCE_ASC">Confidence (Low to High)</option>
|
| 492 |
+
</select>
|
| 493 |
+
<button onClick={() => loadReviewQueue()} disabled={reviewLoading}>
|
| 494 |
+
{reviewLoading ? "Loading..." : "Refresh"}
|
| 495 |
+
</button>
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
{reviewItems.length === 0 ? (
|
| 499 |
+
<p>No items in review queue.</p>
|
| 500 |
+
) : (
|
| 501 |
+
<table className="admin-table">
|
| 502 |
+
<thead>
|
| 503 |
+
<tr>
|
| 504 |
+
<th>File</th>
|
| 505 |
+
<th>Decision</th>
|
| 506 |
+
<th>Confidence</th>
|
| 507 |
+
<th>Risk Score</th>
|
| 508 |
+
<th>Blockers</th>
|
| 509 |
+
<th>Reasons</th>
|
| 510 |
+
<th>Actions</th>
|
| 511 |
+
</tr>
|
| 512 |
+
</thead>
|
| 513 |
+
<tbody>
|
| 514 |
+
{reviewItems.map((item) => (
|
| 515 |
+
<tr key={item.tenderId}>
|
| 516 |
+
<td>{item.fileName}</td>
|
| 517 |
+
<td>
|
| 518 |
+
<span className={`badge ${item.decision?.toLowerCase() || "unknown"}`}>
|
| 519 |
+
{item.decision || "Pending"}
|
| 520 |
+
</span>
|
| 521 |
+
</td>
|
| 522 |
+
<td>{item.confidence ? `${Math.round(item.confidence * 100)}%` : "N/A"}</td>
|
| 523 |
+
<td>{item.riskScore}</td>
|
| 524 |
+
<td>{item.blockerCount} ({item.criticalFailCount} critical)</td>
|
| 525 |
+
<td>{item.reasonCodes.join(", ")}</td>
|
| 526 |
+
<td>
|
| 527 |
+
<div style={{ display: "flex", gap: "0.5rem", flexDirection: "column" }}>
|
| 528 |
+
<select
|
| 529 |
+
value={reviewActionByTender[item.tenderId] || "ACKNOWLEDGE"}
|
| 530 |
+
onChange={(e) => setReviewActionByTender(prev => ({ ...prev, [item.tenderId]: e.target.value as ReviewActionType }))}
|
| 531 |
+
className="btn-sm"
|
| 532 |
+
>
|
| 533 |
+
<option value="ACKNOWLEDGE">Acknowledge</option>
|
| 534 |
+
<option value="REQUEST_REPROCESS">Reprocess</option>
|
| 535 |
+
<option value="APPROVE_FOR_DRAFT">Approve for Draft</option>
|
| 536 |
+
<option value="ESCALATE">Escalate</option>
|
| 537 |
+
</select>
|
| 538 |
+
<input
|
| 539 |
+
type="text"
|
| 540 |
+
placeholder="Note (optional)"
|
| 541 |
+
value={reviewNoteByTender[item.tenderId] || ""}
|
| 542 |
+
onChange={(e) => setReviewNoteByTender(prev => ({ ...prev, [item.tenderId]: e.target.value }))}
|
| 543 |
+
className="btn-sm"
|
| 544 |
+
style={{ minWidth: "120px" }}
|
| 545 |
+
/>
|
| 546 |
+
<button
|
| 547 |
+
onClick={() => submitReviewAction(item.tenderId)}
|
| 548 |
+
className="btn-sm"
|
| 549 |
+
>
|
| 550 |
+
Submit
|
| 551 |
+
</button>
|
| 552 |
+
</div>
|
| 553 |
+
</td>
|
| 554 |
+
</tr>
|
| 555 |
+
))}
|
| 556 |
+
</tbody>
|
| 557 |
+
</table>
|
| 558 |
+
)}
|
| 559 |
+
</section>
|
| 560 |
+
)}
|
| 561 |
+
|
| 562 |
+
{activeTab === "activity" && (
|
| 563 |
+
<section className="admin-activity">
|
| 564 |
+
<h2>Audit Log</h2>
|
| 565 |
+
<table className="admin-table">
|
| 566 |
+
<thead>
|
| 567 |
+
<tr>
|
| 568 |
+
<th>Time</th>
|
| 569 |
+
<th>Admin</th>
|
| 570 |
+
<th>Action</th>
|
| 571 |
+
<th>Resource</th>
|
| 572 |
+
</tr>
|
| 573 |
+
</thead>
|
| 574 |
+
<tbody>
|
| 575 |
+
{activity.map(item => (
|
| 576 |
+
<tr key={item.id}>
|
| 577 |
+
<td>{new Date(item.createdAt).toLocaleString()}</td>
|
| 578 |
+
<td>{item.adminEmail || "Unknown"}</td>
|
| 579 |
+
<td>{item.action}</td>
|
| 580 |
+
<td>{item.resourceType} {item.resourceId && `(${item.resourceId.slice(0, 8)}...)`}</td>
|
| 581 |
+
</tr>
|
| 582 |
+
))}
|
| 583 |
+
</tbody>
|
| 584 |
+
</table>
|
| 585 |
+
</section>
|
| 586 |
+
)}
|
| 587 |
+
</main>
|
| 588 |
+
</div>
|
| 589 |
+
);
|
| 590 |
+
}
|
apps/web/src/app/api/admin/activity/route.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServerClient } from "@supabase/ssr";
|
| 2 |
+
import { cookies } from "next/headers";
|
| 3 |
+
import { NextResponse } from "next/server";
|
| 4 |
+
|
| 5 |
+
async function checkAdminAccess(supabase: ReturnType<typeof createServerClient>) {
|
| 6 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 7 |
+
if (!user) return false;
|
| 8 |
+
|
| 9 |
+
const { data: member } = await supabase
|
| 10 |
+
.from("members")
|
| 11 |
+
.select("is_superuser")
|
| 12 |
+
.eq("user_id", user.id)
|
| 13 |
+
.single();
|
| 14 |
+
|
| 15 |
+
return member?.is_superuser === true;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function GET() {
|
| 19 |
+
const cookieStore = await cookies();
|
| 20 |
+
|
| 21 |
+
const supabase = createServerClient(
|
| 22 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 23 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 24 |
+
{
|
| 25 |
+
cookies: {
|
| 26 |
+
getAll() {
|
| 27 |
+
return cookieStore.getAll();
|
| 28 |
+
},
|
| 29 |
+
setAll() {},
|
| 30 |
+
},
|
| 31 |
+
}
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
const isAdmin = await checkAdminAccess(supabase);
|
| 35 |
+
if (!isAdmin) {
|
| 36 |
+
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
// Get recent audit log entries
|
| 41 |
+
const { data: logs } = await supabase
|
| 42 |
+
.from("admin_audit_log")
|
| 43 |
+
.select("*")
|
| 44 |
+
.order("created_at", { ascending: false })
|
| 45 |
+
.limit(50);
|
| 46 |
+
|
| 47 |
+
// Get user emails for the logs
|
| 48 |
+
const adminIds = [...new Set(logs?.map((l) => l.admin_user_id) ?? [])];
|
| 49 |
+
const { data: authData } = await supabase.auth.admin.listUsers();
|
| 50 |
+
const userMap = new Map(authData?.users?.map((u) => [u.id, u.email]) ?? []);
|
| 51 |
+
|
| 52 |
+
const activity = logs?.map((log) => ({
|
| 53 |
+
id: log.id,
|
| 54 |
+
action: log.action,
|
| 55 |
+
resourceType: log.resource_type,
|
| 56 |
+
resourceId: log.resource_id,
|
| 57 |
+
createdAt: log.created_at,
|
| 58 |
+
adminEmail: userMap.get(log.admin_user_id) ?? "Unknown",
|
| 59 |
+
})) ?? [];
|
| 60 |
+
|
| 61 |
+
return NextResponse.json({ ok: true, data: { activity } });
|
| 62 |
+
} catch (error) {
|
| 63 |
+
return NextResponse.json(
|
| 64 |
+
{ ok: false, error: "Failed to fetch activity" },
|
| 65 |
+
{ status: 500 }
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
}
|
apps/web/src/app/api/admin/check/route.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServerClient } from "@supabase/ssr";
|
| 2 |
+
import { cookies } from "next/headers";
|
| 3 |
+
import { NextResponse } from "next/server";
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
const cookieStore = await cookies();
|
| 7 |
+
|
| 8 |
+
const supabase = createServerClient(
|
| 9 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 10 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 11 |
+
{
|
| 12 |
+
cookies: {
|
| 13 |
+
getAll() {
|
| 14 |
+
return cookieStore.getAll();
|
| 15 |
+
},
|
| 16 |
+
setAll() {
|
| 17 |
+
// Read-only in route handlers
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
}
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 24 |
+
|
| 25 |
+
if (!user) {
|
| 26 |
+
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Check if user is superuser
|
| 30 |
+
const { data: member } = await supabase
|
| 31 |
+
.from("members")
|
| 32 |
+
.select("is_superuser")
|
| 33 |
+
.eq("user_id", user.id)
|
| 34 |
+
.single();
|
| 35 |
+
|
| 36 |
+
if (!member?.is_superuser) {
|
| 37 |
+
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return NextResponse.json({ ok: true, data: { isAdmin: true } });
|
| 41 |
+
}
|
apps/web/src/app/api/admin/jobs/retry/route.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getSupabaseAdmin } from "@/lib/supabase/admin";
|
| 2 |
+
import { createServerClient } from "@supabase/ssr";
|
| 3 |
+
import { cookies } from "next/headers";
|
| 4 |
+
import { NextResponse } from "next/server";
|
| 5 |
+
|
| 6 |
+
async function checkAdminAccess(supabase: ReturnType<typeof createServerClient>) {
|
| 7 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 8 |
+
if (!user) return false;
|
| 9 |
+
|
| 10 |
+
const { data: member } = await supabase
|
| 11 |
+
.from("members")
|
| 12 |
+
.select("is_superuser")
|
| 13 |
+
.eq("user_id", user.id)
|
| 14 |
+
.single();
|
| 15 |
+
|
| 16 |
+
return member?.is_superuser === true;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function POST(req: Request) {
|
| 20 |
+
const cookieStore = await cookies();
|
| 21 |
+
const supabaseAuth = createServerClient(
|
| 22 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 23 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 24 |
+
{
|
| 25 |
+
cookies: {
|
| 26 |
+
getAll() {
|
| 27 |
+
return cookieStore.getAll();
|
| 28 |
+
},
|
| 29 |
+
setAll() {},
|
| 30 |
+
},
|
| 31 |
+
}
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
if (!(await checkAdminAccess(supabaseAuth))) {
|
| 35 |
+
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
const body = await req.json();
|
| 40 |
+
const { jobId, tenderId } = body;
|
| 41 |
+
if (!jobId || !tenderId) {
|
| 42 |
+
return NextResponse.json({ ok: false, error: "Missing parameters" }, { status: 400 });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const supabaseAdmin = getSupabaseAdmin();
|
| 46 |
+
|
| 47 |
+
// Reset the job
|
| 48 |
+
const { error: jobError } = await supabaseAdmin
|
| 49 |
+
.from("processing_jobs")
|
| 50 |
+
.update({
|
| 51 |
+
status: "QUEUED",
|
| 52 |
+
locked_at: null,
|
| 53 |
+
lock_owner: null,
|
| 54 |
+
attempt_count: 0,
|
| 55 |
+
last_error: null,
|
| 56 |
+
updated_at: new Date().toISOString()
|
| 57 |
+
})
|
| 58 |
+
.eq("id", jobId);
|
| 59 |
+
|
| 60 |
+
if (jobError) throw jobError;
|
| 61 |
+
|
| 62 |
+
// Set tender to PROCESSING
|
| 63 |
+
const { error: tenderError } = await supabaseAdmin
|
| 64 |
+
.from("tenders")
|
| 65 |
+
.update({ status: "PROCESSING", updated_at: new Date().toISOString() })
|
| 66 |
+
.eq("id", tenderId);
|
| 67 |
+
|
| 68 |
+
if (tenderError) throw tenderError;
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({ ok: true });
|
| 71 |
+
} catch (error) {
|
| 72 |
+
return NextResponse.json({ ok: false, error: "Failed to retry job" }, { status: 500 });
|
| 73 |
+
}
|
| 74 |
+
}
|
apps/web/src/app/api/admin/jobs/route.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getSupabaseAdmin } from "@/lib/supabase/admin";
|
| 2 |
+
import { createServerClient } from "@supabase/ssr";
|
| 3 |
+
import { cookies } from "next/headers";
|
| 4 |
+
import { NextResponse } from "next/server";
|
| 5 |
+
|
| 6 |
+
async function checkAdminAccess(supabase: ReturnType<typeof createServerClient>) {
|
| 7 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 8 |
+
if (!user) return false;
|
| 9 |
+
|
| 10 |
+
const { data: member } = await supabase
|
| 11 |
+
.from("members")
|
| 12 |
+
.select("is_superuser")
|
| 13 |
+
.eq("user_id", user.id)
|
| 14 |
+
.single();
|
| 15 |
+
|
| 16 |
+
return member?.is_superuser === true;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function GET() {
|
| 20 |
+
const cookieStore = await cookies();
|
| 21 |
+
const supabaseAuth = createServerClient(
|
| 22 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 23 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 24 |
+
{
|
| 25 |
+
cookies: {
|
| 26 |
+
getAll() {
|
| 27 |
+
return cookieStore.getAll();
|
| 28 |
+
},
|
| 29 |
+
setAll() {},
|
| 30 |
+
},
|
| 31 |
+
}
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
if (!(await checkAdminAccess(supabaseAuth))) {
|
| 35 |
+
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
const supabaseAdmin = getSupabaseAdmin();
|
| 40 |
+
// Get failed jobs
|
| 41 |
+
const { data: failedJobs, error } = await supabaseAdmin
|
| 42 |
+
.from("processing_jobs")
|
| 43 |
+
.select("id, tender_id, status, last_error, attempt_count, max_attempts, created_at, tenders(source_filename, organization_id)")
|
| 44 |
+
.eq("status", "FAILED")
|
| 45 |
+
.order("created_at", { ascending: false })
|
| 46 |
+
.limit(50);
|
| 47 |
+
|
| 48 |
+
if (error) throw error;
|
| 49 |
+
return NextResponse.json({ ok: true, data: { failedJobs } });
|
| 50 |
+
} catch (error) {
|
| 51 |
+
return NextResponse.json({ ok: false, error: "Failed to fetch jobs" }, { status: 500 });
|
| 52 |
+
}
|
| 53 |
+
}
|