diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..1ed5d321
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,97 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+EMPACT (Environment and Maturity Program Assessment and Control Tool) is a full-stack assessment platform implementing the IP2M METRR evaluation model. It's built with Next.js 15, TypeScript, and supports both web and desktop deployment via Tauri.
+
+## Development Commands
+
+All commands should be run from the `web/` directory unless specified otherwise:
+
+```bash
+# Development
+yarn dev # Start development server on http://localhost:3000
+
+# Building
+yarn build # Generate Prisma clients and build Next.js app
+yarn start # Start production server
+
+# Code Quality
+yarn lint # Run ESLint
+yarn lint:fix # Run ESLint with auto-fix
+yarn typecheck # Run TypeScript type checking
+
+# Testing
+yarn test # Build and run Playwright tests
+yarn test:ui # Run tests with UI
+yarn test:debug # Run tests in debug mode
+
+# Database
+yarn prisma-generate # Generate Prisma clients for all database types
+
+# Data Import
+yarn import:ip2m # Import legacy IP2M METRR data from Excel
+ # See web/scripts/README.md for detailed documentation
+```
+
+## Architecture Overview
+
+The application uses Next.js App Router with a clear separation between frontend routes and API endpoints:
+
+- **Frontend Routes** (`app/(frontend)/`):
+ - Protected routes under `(logged-in)/` require authentication via Clerk
+ - Public routes under `(logged-out)/` for authentication flows
+ - Key pages: Dashboard, Assessments, Collections, Recommendations, Questionnaire
+
+- **API Layer** (`app/api/`):
+ - RESTful endpoints for data operations
+ - Clerk webhook handling for user synchronization
+ - Database operations via Prisma ORM
+
+- **Database Architecture**:
+ - Multi-database support (MSSQL primary, PostgreSQL/SQLite for flexibility)
+ - Prisma schemas in `web/prisma/[db-type]/schema.prisma`
+ - Generated clients in `web/prisma/generated/[db-type]/`
+ - Use `getDatabaseClient()` from `web/lib/db.ts` for database access
+
+- **Component Structure**:
+ - Shared components in `web/components/`
+ - UI components from Shadcn UI library
+ - All components use TypeScript with proper type definitions
+
+## Key Patterns and Conventions
+
+1. **Authentication**:
+ - Clerk handles authentication with SSO support
+ - User sync happens via webhooks to maintain database records
+ - Check authentication state using Clerk's hooks/utilities
+
+2. **Database Access**:
+ - Always use the database client from `lib/db.ts`
+ - Follow existing query patterns in API routes
+ - Use Prisma's type-safe query builders
+
+3. **State Management**:
+ - Server components by default in App Router
+ - Client components marked with 'use client'
+ - Use URL state for filters and pagination
+
+4. **Error Handling**:
+ - API routes return appropriate HTTP status codes
+ - Client-side error boundaries for graceful degradation
+ - Consistent error response format
+
+5. **Testing**:
+ - Playwright for E2E tests
+ - Tests located in `web/tests/`
+ - Follow existing test patterns for new features
+
+## Important Context
+
+- The project implements the IP2M METRR evaluation model for project maturity assessment
+- Three main user roles: System Admin, Collection Manager, Assessment Manager
+- Collections group related assessments together
+- Recent work includes facilitator-controlled stopwatch functionality for timed assessments
+- The application supports both online (web) and offline (Tauri desktop) usage
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index 45f8a66f..4d83d4ac 100644
--- a/web/package.json
+++ b/web/package.json
@@ -31,7 +31,8 @@
"test:ui:dev": "cross-env PLAYWRIGHT_SERVER_COMMAND=\"yarn dev\" playwright test --ui",
"test:ci": "playwright test --reporter=list",
"test:nobuild:ui": "playwright test --ui",
- "test:nobuild": "playwright test"
+ "test:nobuild": "playwright test",
+ "import:ip2m": "npx tsx scripts/import-ip2m-data-v2.ts"
},
"dependencies": {
"@clerk/nextjs": "^6.12.6",
@@ -101,6 +102,7 @@
"@types/node": "20.14.15",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.4",
+ "@types/sanitize-html": "^2.16.0",
"@typescript-eslint/eslint-plugin": "^8.27.0",
"@typescript-eslint/parser": "^8.27.0",
"autoprefixer": "^10.4.21",
@@ -117,7 +119,9 @@
"prettier-plugin-organize-imports": "^4.1.0",
"prisma-dbml-generator": "0.12.0",
"prompt-confirm": "2.0.4",
- "tailwindcss": "3.4.17"
+ "sanitize-html": "^2.17.0",
+ "tailwindcss": "3.4.17",
+ "xlsx": "^0.18.5"
},
"pkg": {
"scripts": ".next/standalone/**/*.*",
diff --git a/web/scripts/README.md b/web/scripts/README.md
new file mode 100644
index 00000000..5ae6681a
--- /dev/null
+++ b/web/scripts/README.md
@@ -0,0 +1,224 @@
+# IP2M METRR Data Import Utility
+
+This utility imports legacy assessment data from the IP2M METRR system into EMPACT. It was created to migrate historical assessment data while preserving all responses and maintaining data integrity.
+
+## Usage
+
+```bash
+# Dry run (preview what will be imported)
+yarn import:ip2m --file="path/to/excel-file.xlsx" --dryRun
+
+# Actual import
+yarn import:ip2m --file="path/to/excel-file.xlsx"
+```
+
+## Excel File Format Specification
+
+The Excel file must contain exactly two sheets with specific column structures:
+
+### Sheet 1: `assessment`
+
+This sheet contains assessment metadata. Expected columns:
+
+| Column Name | Type | Description | Example |
+|------------|------|-------------|---------|
+| `id` | number | Legacy assessment ID (not used) | 130 |
+| `project_id` | string | Project identifier | "LCCF" |
+| `name` | string | Assessment name | "LCCF EVMS Review" |
+| `type_id` | number | Legacy type ID (not used) | 16 |
+| `type` | string | Legacy type name (not used) | "NSF Project" |
+| `location` | string | Assessment location | "Austin, Texas" |
+| `date` | number | JavaScript timestamp (milliseconds since epoch) | 1729712460000 |
+| `manager` | string | Manager name (not used) | "" |
+| `description` | string | Assessment description (can contain HTML) | "" |
+| `hide_description` | boolean | (not used) | true |
+| `status` | string | Assessment status: "STARTED", "COMPLETED", etc. | "STARTED" |
+| `has_maturity` | boolean | (not used) | false |
+| `has_environment` | boolean | (not used) | true |
+| `maturity_score` | number | Maturity score (if available) | 842 |
+| `current_progress` | string | (not used) | "IN_PROGRESS" |
+| `percent_completed` | number | (not used) | 0 |
+| `maturity_progress` | string | (not used) | "COMPLETED" |
+| `env_progress` | string | (not used) | "COMPLETED" |
+| `is_env_anonymous` | boolean | (not used) | true |
+| `lock_env` | boolean | (not used) | false |
+| `internal_assessment_status` | string | (not used) | "ACTIVE" |
+| `factor_facilitator_answers` | boolean | (not used) | false |
+| `environment_score` | number | Environment score (if available) | 926 |
+
+### Sheet 2: `assessment_user_responses`
+
+This sheet contains all user responses. Expected columns:
+
+| Column Name | Type | Description | Example |
+|------------|------|-------------|---------|
+| `id` | number | Legacy response ID | 18030 |
+| `user_id` | number | Legacy user ID | 381 |
+| `attribute_id` | string | EMPACT attribute ID | "1a" |
+| `level_id` | number | Legacy level ID (not used directly) | 340 |
+| `notes` | string | Response notes (can contain HTML) | "
project saying...
" |
+| `section_id` | string | Section ID for validation | "1" |
+| `is_facilitator_response` | boolean | (not used) | false |
+| `attributes.name` | string | Attribute name (for reference) | "The contractor organization..." |
+| `attributes.description` | string | Attribute description (for reference) | "The contractor's...
" |
+| `levels.short_description` | string | Level short description | "Meets Most" |
+| `levels.long_description` | string | Level long description | "Rating a factor..." |
+| `levels.level_number` | number | Level number (1-5) - CRITICAL for mapping | 4 |
+| `levels.weight` | number | Level weight/score | 58 |
+| `sections.name` | string | Section name | "Culture" |
+| `sections.description` | string | Section description | "The culture category..." |
+
+## Import Process
+
+The import utility performs the following steps:
+
+### 1. Validation Phase
+- Verifies IP2M METRR assessment type exists in EMPACT
+- Validates all attribute IDs from responses exist in EMPACT
+- Maps legacy level numbers to EMPACT level IDs
+- Checks for existing data to prevent duplicates
+
+### 2. Import Phase (in transaction)
+For each assessment in the Excel file:
+
+1. **Create Assessment Collection**
+ - Name: "Import - [Assessment Name]"
+ - Type: IP2M METRR
+
+2. **Create Assessment**
+ - Project ID, name, location from Excel
+ - Status mapping:
+ - "STARTED" → "Active"
+ - "COMPLETED" → "Final"
+ - "NOT_STARTED" → "Inactive"
+ - Completed date set if status is "COMPLETED"
+ - HTML cleaned from description
+
+3. **Create Assessment Parts**
+ - Creates parts for Environment and Maturity
+ - Status: "Active"
+ - Date from Excel timestamp
+
+4. **Add Assessment Attributes**
+ - Links all 83 IP2M METRR attributes to assessment
+
+5. **Create User Group**
+ - Name: "Imported Participants"
+ - Status: "Active"
+
+6. **Create/Find Users**
+ - Email format: `imported_ip2m_user_{legacy_id}@doe.gov`
+ - Name: "IP2M User {legacy_id}"
+ - Checks for existing users to avoid duplicates
+
+7. **Create Assessment Users**
+ - Role: "Participant"
+ - Linked to user group
+ - Connected to all assessment parts
+ - No permissions (not real users)
+
+8. **Import Responses**
+ - Maps attribute IDs directly
+ - Maps level numbers to EMPACT level IDs
+ - Cleans HTML from notes
+ - Creates AssessmentUserResponse records
+
+### 3. Post-Import Notes
+- Score summaries are skipped due to database schema mismatch
+- All operations occur in a transaction (all-or-nothing)
+- 60-second timeout for large imports
+
+## Data Mapping Details
+
+### Status Mapping
+```
+Excel Status → EMPACT Status
+STARTED → Active
+COMPLETED → Final
+NOT_STARTED → Inactive
+ACTIVE → Active
+INACTIVE → Inactive
+FINAL → Final
+ARCHIVED → Archived
+(default) → Active
+```
+
+### Level Mapping
+Levels are mapped using the combination of attribute ID and level number:
+- Key: `{attribute_id}-{level_number}` (e.g., "1a-4")
+- Maps to EMPACT level ID
+
+### HTML Cleaning
+Basic HTML tags and entities are removed from:
+- Assessment descriptions
+- Response notes
+
+Conversions:
+- ``, `
`, etc. → removed
+- ` ` → space
+- `’` → '
+- `“` → "
+- `”` → "
+- `&` → &
+
+## Prerequisites
+
+1. **IP2M METRR Assessment Type** must exist in EMPACT database
+2. **All Attributes** referenced in responses must exist in EMPACT
+3. **All Levels** must exist for each attribute (levels 0-5)
+4. **Database Access** with write permissions
+
+## Error Handling
+
+- **Validation Errors**: Stop before import if data doesn't match
+- **Duplicate Users**: Reuses existing imported users
+- **Duplicate Assessment Users**: Logs warning but continues
+- **Missing Level Mappings**: Logs error and skips that response
+- **Transaction Rollback**: All changes reverted if any error occurs
+
+## Output
+
+The import provides detailed progress information:
+```
+📊 Starting IP2M METRR Data Import
+✅ IP2M METRR Assessment type found (ID: 1)
+✅ All 83 attributes validated
+📁 Processing assessment: LCCF EVMS Review
+ ✅ Created assessment (ID: 15)
+ ✅ Created 2 assessment parts
+ ✅ Added 83 attributes to assessment
+ ✅ Created user group for imported participants
+ ✅ Created user: imported_ip2m_user_381@doe.gov
+ ✅ Processed 16 users
+ ✅ Imported 466 responses
+🎉 Import completed successfully!
+```
+
+## Troubleshooting
+
+### "IP2M METRR assessment type not found"
+The IP2M METRR assessment type doesn't exist in the database. This is required base data.
+
+### "Missing attributes in EMPACT database"
+Some attributes in the Excel file don't exist in EMPACT. Check that all IP2M METRR attributes are loaded.
+
+### "Cannot find level mapping"
+The level number for an attribute doesn't exist. Verify levels 0-5 exist for all attributes.
+
+### "The column 'type' does not exist"
+Database schema mismatch. The ScoreSummary table structure differs from Prisma schema.
+
+## Source Code Location
+
+The import utility is located at:
+```
+web/scripts/import-ip2m-data-v2.ts
+```
+
+## Future Improvements
+
+1. **Score Calculation**: Calculate scores from responses rather than using Excel values
+2. **User Mapping**: Option to map legacy users to real EMPACT users
+3. **Multiple Assessments**: Support multiple assessments in one Excel file
+4. **Validation Report**: Generate pre-import validation report
+5. **Export Utility**: Create matching export functionality
\ No newline at end of file
diff --git a/web/scripts/import-ip2m-data-v2.ts b/web/scripts/import-ip2m-data-v2.ts
new file mode 100644
index 00000000..8cb3fe9a
--- /dev/null
+++ b/web/scripts/import-ip2m-data-v2.ts
@@ -0,0 +1,363 @@
+import { PrismaClient } from '../prisma/mssql/generated/client'
+import * as XLSX from 'xlsx'
+import { parseArgs } from 'node:util'
+import sanitizeHtml from 'sanitize-html'
+
+const prisma = new PrismaClient({
+ log: ['info', 'warn', 'error'],
+})
+
+interface ExcelAssessment {
+ id: number
+ project_id: string
+ name: string
+ type_id: number
+ type: string
+ location: string
+ date: number
+ manager: string
+ description: string
+ hide_description: boolean
+ status: string
+ has_maturity: boolean
+ has_environment: boolean
+ maturity_score: number
+ current_progress: string
+ percent_completed: number
+ maturity_progress: string
+ env_progress: string
+ is_env_anonymous: boolean
+ lock_env: boolean
+ internal_assessment_status: string
+ factor_facilitator_answers: boolean
+ environment_score: number
+}
+
+interface ExcelUserResponse {
+ id: number
+ user_id: number
+ attribute_id: string
+ level_id: number
+ notes: string
+ section_id: string
+ is_facilitator_response: boolean
+ 'attributes.name': string
+ 'attributes.description': string
+ 'levels.short_description': string
+ 'levels.long_description': string
+ 'levels.level_number': number
+ 'levels.weight': number
+ 'sections.name': string
+ 'sections.description': string
+}
+
+const options = {
+ file: { type: 'string' as const },
+ dryRun: { type: 'boolean' as const },
+}
+
+async function main() {
+ const {
+ values: { file, dryRun },
+ } = parseArgs({ options })
+
+ if (!file) {
+ console.error('Please provide a file path with --file parameter')
+ process.exit(1)
+ }
+
+ console.log(`\n📊 Starting IP2M METRR Data Import`)
+ console.log(`File: ${file}`)
+ console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE IMPORT'}`)
+ console.log('─'.repeat(50))
+
+ try {
+ // Read Excel file
+ const workbook = XLSX.readFile(file)
+ const assessmentSheet = workbook.Sheets['assessment']
+ const responseSheet = workbook.Sheets['assessment_user_responses']
+
+ const assessmentData = XLSX.utils.sheet_to_json(assessmentSheet)
+ const responseData = XLSX.utils.sheet_to_json(responseSheet)
+
+ console.log(`\n📋 Found ${assessmentData.length} assessment(s)`)
+ console.log(`📝 Found ${responseData.length} responses`)
+
+ // Pre-validate all data before starting transaction
+ const assessmentType = await prisma.assessmentType.findFirst({
+ where: { name: 'IP2M METRR' },
+ include: { parts: true }
+ })
+
+ if (!assessmentType) {
+ throw new Error('IP2M METRR assessment type not found in database.')
+ }
+
+ console.log(`\n✅ IP2M METRR Assessment type found (ID: ${assessmentType.id})`)
+
+ // Get all attributes for validation
+ const attributes = await prisma.attribute.findMany()
+ const attributeMap = new Map(attributes.map(a => [a.id, a]))
+
+ // Validate all attribute IDs exist
+ const uniqueAttributeIds = [...new Set(responseData.map(r => r.attribute_id))]
+ const missingAttributes = uniqueAttributeIds.filter(id => !attributeMap.has(id))
+
+ if (missingAttributes.length > 0) {
+ console.error(`\n❌ Missing attributes in EMPACT database: ${missingAttributes.join(', ')}`)
+ throw new Error('Some attributes from Excel do not exist in EMPACT database')
+ }
+
+ console.log(`✅ All ${uniqueAttributeIds.length} attributes validated`)
+
+ // Map legacy level IDs to EMPACT level IDs
+ const levels = await prisma.level.findMany()
+ const levelMap = new Map()
+
+ // Create mapping key: attributeId + levelNumber -> levelId
+ levels.forEach(level => {
+ const key = `${level.attributeId}-${level.level}`
+ levelMap.set(key, level.id)
+ })
+
+
+ if (dryRun) {
+ console.log('\n🔍 DRY RUN - No changes will be made to the database')
+ console.log('\nPlanned actions:')
+
+ for (const excelAssessment of assessmentData) {
+ console.log(`\n📁 Processing assessment: ${excelAssessment.name}`)
+ const assessmentDate = new Date(excelAssessment.date)
+
+ console.log(` - Would create assessment collection: "Import - ${excelAssessment.name}"`)
+ console.log(` - Would create assessment with:`)
+ console.log(` - Project ID: ${excelAssessment.project_id}`)
+ console.log(` - Name: ${excelAssessment.name}`)
+ console.log(` - Location: ${excelAssessment.location}`)
+ console.log(` - Status: ${mapStatus(excelAssessment.status)}`)
+
+ // Get unique users from ALL responses (no filter)
+ const uniqueUserIds = [...new Set(responseData.map(r => r.user_id))]
+ console.log(` - Would create/use ${uniqueUserIds.length} users`)
+ console.log(` - Would create 1 assessment user group for all imported users`)
+ console.log(` - Would import ${responseData.length} responses`)
+ }
+
+ console.log('\n✅ Dry run completed. Run without --dryRun to perform actual import.')
+ return
+ }
+
+ // Execute import in transaction
+ await prisma.$transaction(async (tx) => {
+ // Process each assessment
+ for (const excelAssessment of assessmentData) {
+ console.log(`\n📁 Processing assessment: ${excelAssessment.name}`)
+
+ const assessmentDate = new Date(excelAssessment.date)
+ const isCompleted = excelAssessment.status === 'COMPLETED'
+
+ // Create assessment collection
+ const collection = await tx.assessmentCollection.create({
+ data: {
+ name: `Import - ${excelAssessment.name}`,
+ assessmentTypeId: assessmentType.id,
+ }
+ })
+
+ // Create assessment
+ const assessment = await tx.assessment.create({
+ data: {
+ projectId: excelAssessment.project_id,
+ name: excelAssessment.name,
+ location: excelAssessment.location,
+ description: cleanHtml(excelAssessment.description || ''),
+ status: mapStatus(excelAssessment.status),
+ completedDate: isCompleted ? assessmentDate : null,
+ assessmentCollectionId: collection.id,
+ }
+ })
+
+ console.log(` ✅ Created assessment (ID: ${assessment.id})`)
+
+ // Create assessment parts
+ const createdParts = []
+ for (const part of assessmentType.parts) {
+ const assessmentPart = await tx.assessmentPart.create({
+ data: {
+ assessmentId: assessment.id,
+ partId: part.id,
+ status: 'Active',
+ date: assessmentDate,
+ }
+ })
+ createdParts.push(assessmentPart)
+ }
+
+ console.log(` ✅ Created ${assessmentType.parts.length} assessment parts`)
+
+ // Add all attributes to assessment
+ const assessmentAttributes = uniqueAttributeIds.map(attributeId => ({
+ assessmentId: assessment.id,
+ attributeId: attributeId,
+ }))
+
+ await tx.assessmentAttribute.createMany({
+ data: assessmentAttributes,
+ })
+
+ console.log(` ✅ Added ${assessmentAttributes.length} attributes to assessment`)
+
+ // Create a single user group for all imported users
+ const userGroup = await tx.assessmentUserGroup.create({
+ data: {
+ name: 'Imported Participants',
+ assessmentId: assessment.id,
+ status: 'Active'
+ }
+ })
+
+ console.log(` ✅ Created user group for imported participants`)
+
+ // Get unique users from ALL responses (removed filter)
+ const uniqueUserIds = [...new Set(responseData.map(r => r.user_id))]
+
+ // Create or find users
+ const userMap = new Map()
+
+ for (const legacyUserId of uniqueUserIds) {
+ const email = `imported_ip2m_user_${legacyUserId}@doe.gov`
+
+ let user = await tx.user.findUnique({
+ where: { email }
+ })
+
+ if (!user) {
+ user = await tx.user.create({
+ data: {
+ email,
+ firstName: 'IP2M',
+ lastName: `User ${legacyUserId}`,
+ }
+ })
+ console.log(` ✅ Created user: ${email}`)
+ }
+
+ userMap.set(legacyUserId, user.id)
+
+ // Add user to assessment - no permissions needed for imported users
+ try {
+ await tx.assessmentUser.create({
+ data: {
+ assessmentId: assessment.id,
+ userId: user.id,
+ role: 'Participant',
+ assessmentUserGroupId: userGroup.id,
+ // Connect to all assessment parts for participants
+ participantParts: {
+ connect: createdParts.map(p => ({ id: p.id }))
+ }
+ }
+ })
+ } catch (error: any) {
+ if (error.code === 'P2002') {
+ // Unique constraint violation - user already exists for this assessment
+ console.log(` ℹ️ User ${email} already assigned to assessment`)
+ } else {
+ console.error(` ⚠️ Error creating assessment user for ${email}:`, error.message)
+ }
+ }
+ }
+
+ console.log(` ✅ Processed ${uniqueUserIds.length} users`)
+
+ // Import ALL responses
+ let successCount = 0
+ let errorCount = 0
+
+ for (const response of responseData) {
+ const empactUserId = userMap.get(response.user_id)
+ if (!empactUserId) {
+ console.error(` ⚠️ Cannot find user mapping for legacy user ${response.user_id}`)
+ errorCount++
+ continue
+ }
+
+ // Map level ID
+ const levelKey = `${response.attribute_id}-${response['levels.level_number']}`
+ const empactLevelId = levelMap.get(levelKey)
+
+ if (!empactLevelId) {
+ console.error(` ⚠️ Cannot find level mapping for ${levelKey}`)
+ errorCount++
+ continue
+ }
+
+ try {
+ await tx.assessmentUserResponse.create({
+ data: {
+ assessmentId: assessment.id,
+ userId: empactUserId,
+ assessmentUserGroupId: userGroup.id,
+ attributeId: response.attribute_id,
+ levelId: empactLevelId,
+ notes: cleanHtml(response.notes || ''),
+ }
+ })
+ successCount++
+ } catch (error: any) {
+ console.error(` ⚠️ Error importing response: ${error.message}`)
+ errorCount++
+ }
+ }
+
+ console.log(` ✅ Imported ${successCount} responses`)
+ if (errorCount > 0) {
+ console.log(` ⚠️ Failed to import ${errorCount} responses`)
+ }
+
+ // Skip score summaries - schema mismatch with database
+ if (excelAssessment.maturity_score || excelAssessment.environment_score) {
+ console.log(` ℹ️ Skipping score summaries (database schema mismatch)`)
+ }
+ }
+ }, {
+ timeout: 60000 // 60 second timeout for large imports
+ })
+
+ console.log('\n🎉 Import completed successfully!')
+
+ } catch (error) {
+ console.error('\n❌ Import failed:', error)
+ process.exit(1)
+ } finally {
+ await prisma.$disconnect()
+ }
+}
+
+function mapStatus(excelStatus: string): string {
+ // Map Excel statuses to EMPACT statuses
+ const statusMap: Record = {
+ 'STARTED': 'Active',
+ 'COMPLETED': 'Final',
+ 'NOT_STARTED': 'Inactive',
+ 'ACTIVE': 'Active',
+ 'INACTIVE': 'Inactive',
+ 'FINAL': 'Final',
+ 'ARCHIVED': 'Archived'
+ }
+ return statusMap[excelStatus.toUpperCase()] || 'Active'
+}
+
+function cleanHtml(html: string): string {
+ // Use sanitize-html to remove all HTML tags and attributes
+ return sanitizeHtml(html, {
+ allowedTags: [],
+ allowedAttributes: {}
+ }).trim()
+}
+
+main().catch(async (e) => {
+ console.error(e)
+ await prisma.$disconnect()
+ process.exit(1)
+})
\ No newline at end of file
diff --git a/web/scripts/tsconfig.json b/web/scripts/tsconfig.json
new file mode 100644
index 00000000..2ad1dbaa
--- /dev/null
+++ b/web/scripts/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "noEmit": false,
+ "esModuleInterop": true
+ },
+ "include": ["./**/*"]
+}
\ No newline at end of file
diff --git a/web/yarn.lock b/web/yarn.lock
index 67320bd8..3d0fa3eb 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -1783,6 +1783,13 @@
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
+"@types/sanitize-html@^2.16.0":
+ version "2.16.0"
+ resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.16.0.tgz#860d72c1ba8a5d044946f37559cc359c0a13b24e"
+ integrity sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==
+ dependencies:
+ htmlparser2 "^8.0.0"
+
"@typescript-eslint/eslint-plugin@^8.27.0":
version "8.27.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz#fbef10802365832ee1d1bd5d2117dcec82727a72"
@@ -2024,6 +2031,11 @@ acorn@^8.14.0, acorn@^8.9.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
+adler-32@~1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2"
+ integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
+
ag-charts-types@11.2.3:
version "11.2.3"
resolved "https://registry.yarnpkg.com/ag-charts-types/-/ag-charts-types-11.2.3.tgz#482a7b56f0a0d775627ffc786d7562ac2da1db5d"
@@ -2683,6 +2695,14 @@ caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz#6b33a8857e6c7dcb41a0caa2dd0f0489c823a52d"
integrity sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==
+cfb@~1.2.1:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44"
+ integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
+ dependencies:
+ adler-32 "~1.3.0"
+ crc-32 "~1.2.0"
+
chalk@^4.0.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
@@ -2790,6 +2810,11 @@ cmdk@1.0.0:
"@radix-ui/react-dialog" "1.0.5"
"@radix-ui/react-primitive" "1.0.3"
+codepage@~1.15.0:
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab"
+ integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
+
collection-visit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -2881,7 +2906,7 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
-crc-32@^1.2.0:
+crc-32@^1.2.0, crc-32@~1.2.0, crc-32@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
@@ -3004,6 +3029,11 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+deepmerge@^4.2.2:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+ integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
+
define-data-property@^1.0.1, define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
@@ -3104,6 +3134,36 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
+dom-serializer@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
+ integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
+ dependencies:
+ domelementtype "^2.3.0"
+ domhandler "^5.0.2"
+ entities "^4.2.0"
+
+domelementtype@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+ integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
+
+domhandler@^5.0.2, domhandler@^5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
+ integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
+ dependencies:
+ domelementtype "^2.3.0"
+
+domutils@^3.0.1:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
+ integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
+ dependencies:
+ dom-serializer "^2.0.0"
+ domelementtype "^2.3.0"
+ domhandler "^5.0.3"
+
dot-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
@@ -3171,6 +3231,11 @@ end-of-stream@^1.4.1:
dependencies:
once "^1.4.0"
+entities@^4.2.0, entities@^4.4.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
env-paths@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@@ -3770,6 +3835,11 @@ fp-ts@2.16.0:
resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.16.0.tgz#64e03314dfc1c7ce5e975d3496ac14bc3eb7f92e"
integrity sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==
+frac@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b"
+ integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
+
fraction.js@^4.3.7:
version "4.3.7"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
@@ -4068,6 +4138,16 @@ hosted-git-info@^2.1.4:
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
+htmlparser2@^8.0.0:
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
+ integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
+ dependencies:
+ domelementtype "^2.3.0"
+ domhandler "^5.0.3"
+ domutils "^3.0.1"
+ entities "^4.4.0"
+
http-proxy-agent@7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz#e9096c5afd071a3fce56e6252bb321583c124673"
@@ -4380,6 +4460,11 @@ is-plain-object@^2.0.4:
dependencies:
isobject "^3.0.1"
+is-plain-object@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+ integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
+
is-regex@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
@@ -4902,7 +4987,7 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
-nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8:
+nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
@@ -5241,6 +5326,11 @@ parse-json@^5.0.0:
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
+parse-srcset@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
+ integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
+
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@@ -5392,6 +5482,15 @@ postcss@8.4.41:
picocolors "^1.0.1"
source-map-js "^1.2.0"
+postcss@^8.3.11:
+ version "8.5.6"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
+ integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
postcss@^8.4.4, postcss@^8.4.47:
version "8.5.3"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb"
@@ -5854,6 +5953,18 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
+sanitize-html@^2.17.0:
+ version "2.17.0"
+ resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.0.tgz#a8f66420a6be981d8fe412e3397cc753782598e4"
+ integrity sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==
+ dependencies:
+ deepmerge "^4.2.2"
+ escape-string-regexp "^4.0.0"
+ htmlparser2 "^8.0.0"
+ is-plain-object "^5.0.0"
+ parse-srcset "^1.0.2"
+ postcss "^8.3.11"
+
scheduler@^0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
@@ -6123,6 +6234,13 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3"
integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==
+ssf@~0.11.2:
+ version "0.11.2"
+ resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c"
+ integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
+ dependencies:
+ frac "~1.1.2"
+
stable-hash@^0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stable-hash/-/stable-hash-0.0.5.tgz#94e8837aaeac5b4d0f631d2972adef2924b40269"
@@ -6869,11 +6987,21 @@ window-size@^1.1.0:
define-property "^1.0.0"
is-number "^3.0.0"
+wmf@~1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da"
+ integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
+
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
+word@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961"
+ integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
+
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
@@ -6897,6 +7025,19 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+xlsx@^0.18.5:
+ version "0.18.5"
+ resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0"
+ integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
+ dependencies:
+ adler-32 "~1.3.0"
+ cfb "~1.2.1"
+ codepage "~1.15.0"
+ crc-32 "~1.2.1"
+ ssf "~0.11.2"
+ wmf "~1.0.1"
+ word "~0.3.0"
+
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"