engresearch commited on
Commit
7f88bdf
·
verified ·
1 Parent(s): 2010013

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +45 -40
  2. .gitattributes +1 -0
  3. .gitignore +25 -0
  4. .gitmodules +0 -0
  5. .pytest_cache/.gitignore +2 -0
  6. .pytest_cache/CACHEDIR.TAG +4 -0
  7. .pytest_cache/README.md +8 -0
  8. .pytest_cache/v/cache/nodeids +1 -0
  9. AGENTS.md +8 -0
  10. TenderHub Kenya Build Plan.md +66 -0
  11. WORKERS_ARCHITECTURE.md +438 -0
  12. apps/web/.gitignore +2 -0
  13. apps/web/e2e/basic.spec.ts +15 -0
  14. apps/web/eslint.config.mjs +5 -0
  15. apps/web/jest.config.cjs +28 -0
  16. apps/web/jest.setup.js +37 -0
  17. apps/web/netlify.toml +6 -0
  18. apps/web/next-env.d.ts +6 -0
  19. apps/web/next.config.ts +14 -0
  20. apps/web/package.json +92 -0
  21. apps/web/playwright.config.ts +28 -0
  22. apps/web/postcss.config.js +6 -0
  23. apps/web/public/icons/icon-192.png +0 -0
  24. apps/web/public/icons/icon-512.png +0 -0
  25. apps/web/public/manifest.json +27 -0
  26. apps/web/public/sw.js +1 -0
  27. apps/web/public/workbox-b52a85cb.js +1 -0
  28. apps/web/scratch/check_user.cjs +31 -0
  29. apps/web/scratch/delete_user.cjs +37 -0
  30. apps/web/scratch/get_org_id.cjs +51 -0
  31. apps/web/scratch/setup_org.cjs +42 -0
  32. apps/web/src/__tests__/api/mpesa-stk.test.ts +18 -0
  33. apps/web/src/__tests__/api/tenders-drafts-export.test.ts +14 -0
  34. apps/web/src/__tests__/api/tenders-unlock.test.ts +16 -0
  35. apps/web/src/__tests__/api/uploads-init.test.ts +18 -0
  36. apps/web/src/app/(dashboard)/consultant/page.tsx +118 -0
  37. apps/web/src/app/(dashboard)/dashboard/page.tsx +170 -0
  38. apps/web/src/app/(dashboard)/layout.tsx +29 -0
  39. apps/web/src/app/(dashboard)/proposals/[id]/page.tsx +203 -0
  40. apps/web/src/app/(dashboard)/proposals/page.tsx +68 -0
  41. apps/web/src/app/(dashboard)/settings/page.tsx +223 -0
  42. apps/web/src/app/(dashboard)/tenders/[id]/page.tsx +276 -0
  43. apps/web/src/app/(dashboard)/tenders/page.tsx +113 -0
  44. apps/web/src/app/(dashboard)/upload/page.tsx +217 -0
  45. apps/web/src/app/admin/layout.tsx +249 -0
  46. apps/web/src/app/admin/page.tsx +590 -0
  47. apps/web/src/app/api/admin/activity/route.ts +68 -0
  48. apps/web/src/app/api/admin/check/route.ts +41 -0
  49. apps/web/src/app/api/admin/jobs/retry/route.ts +74 -0
  50. apps/web/src/app/api/admin/jobs/route.ts +53 -0
.env.example CHANGED
@@ -1,40 +1,45 @@
1
- # Database Configuration
2
- DATABASE_URL=postgresql://username:password@host:port/database
3
-
4
- # Supabase Configuration
5
- NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
6
- SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
7
- SUPABASE_STORAGE_BUCKET=tender-documents
8
-
9
- # WebAI Model Configuration
10
- # Optional: Override default model name
11
- # WEBAI_MODEL_NAME=webAI-Official/webAI-ColVec1-4b
12
-
13
- # Optional: Configure quantization (default: 8-bit for 4B model)
14
- # USE_8BIT_QUANTIZATION=true
15
- # USE_4BIT_QUANTIZATION=false
16
-
17
- # Optional: Configure FlashAttention-2 for memory optimization (default: true)
18
- # USE_FLASH_ATTENTION_2=true
19
-
20
- # Optional: Configure DPI for PDF conversion (default: 200)
21
- # Higher DPI improves extraction quality but uses more memory
22
- # PDF_DPI=300
23
-
24
- # Optional: Enable adaptive DPI scaling based on available memory (default: true)
25
- # ADAPTIVE_DPI=true
26
-
27
- # Optional: Configure max tokens (default: 512)
28
- # MAX_NEW_TOKENS=512
29
-
30
- # Optional: Configure image size (default: 336)
31
- # IMAGE_SIZE=336
32
-
33
- # Optional: Configure batch size (default: 1)
34
- # BATCH_SIZE=1
35
-
36
- # Optional: Configure timeout (default: 300)
37
- # TIMEOUT_SECONDS=300
38
-
39
- # HF Spaces Configuration
40
- PORT=7860
 
 
 
 
 
 
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
+ }