Compare commits

..

5 Commits

Author SHA1 Message Date
kenilkb
4715e1b482 Chatbot_phase2_2407 2026-07-24 17:05:44 +05:30
kenilkb
4256143939 Chatbot_phase1_2307 2026-07-23 20:18:28 +05:30
kenilkb
67acafd245 Chatbot_highlight 2026-07-22 16:59:45 +05:30
kenilkb
a127b5ffc4 Valid_filters_and_texanomy 2026-07-22 12:33:24 +05:30
kenilkb
61f537057b Asset Management & seach, filter funationalities 2026-07-21 14:11:41 +05:30
60 changed files with 8037 additions and 4717 deletions

6
.gitignore vendored
View File

@ -36,3 +36,9 @@ uploads/*
/phases.md /phases.md
/architecture.md /architecture.md
/Guide.md /Guide.md
# Dedicated Documentation Folder
/documents/
/minio-seed/
ai-advisor.md
testing-strategy.md

View File

@ -1,95 +0,0 @@
const fs = require('fs');
async function runTests() {
const BASE_URL = 'http://localhost:5001/api/v1';
let token = '';
try {
console.log('1. Testing Health Endpoint...');
const health = await fetch(`${BASE_URL}/health`);
const healthData = await health.json();
console.log('Health:', healthData);
if (!health.ok) throw new Error('Health check failed');
console.log('\n2. Testing Registration...');
const regRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword', role: 'ADMIN' })
});
console.log('Registration Status (Admin):', regRes.status);
if (!regRes.ok && regRes.status !== 400) throw new Error('Registration failed');
const regPartnerRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'partner@tech4biz.com', password: 'securepassword', role: 'PARTNER_USER' })
});
console.log('Registration Status (Partner):', regPartnerRes.status);
if (!regPartnerRes.ok && regPartnerRes.status !== 400) throw new Error('Partner Registration failed');
console.log('\n3. Testing Login...');
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword' })
});
const loginData = await loginRes.json();
console.log('Login Status:', loginRes.status);
if (!loginRes.ok) throw new Error('Login failed');
token = loginData.accessToken;
console.log('Received Access Token: ', token.substring(0, 15) + '...');
console.log('\n4. Testing Organization Creation...');
const orgRes = await fetch(`${BASE_URL}/organizations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Tech4Biz Partners' })
});
const orgData = await orgRes.json();
console.log('Organization Created:', orgData);
if (!orgRes.ok) throw new Error('Org creation failed');
console.log('\n5. Testing Legal Document Creation...');
const legalRes = await fetch(`${BASE_URL}/legal/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'NDA', version: '1.0', content: 'You must not disclose anything.' })
});
const legalData = await legalRes.json();
console.log('Legal Document Created:', legalData);
if (!legalRes.ok) throw new Error('Legal creation failed');
console.log('\n6. Testing Asset Upload...');
// Create a dummy file
fs.writeFileSync('test-file.txt', 'This is a test file for upload.');
const formData = new FormData();
const fileBlob = new Blob([fs.readFileSync('test-file.txt')], { type: 'text/plain' });
formData.append('file', fileBlob, 'test-file.txt');
formData.append('title', 'My Secret Document');
const uploadRes = await fetch(`${BASE_URL}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const uploadData = await uploadRes.json();
console.log('Asset Uploaded:', uploadData);
if (!uploadRes.ok) throw new Error('Upload failed');
fs.unlinkSync('test-file.txt');
console.log('\n✅ ALL TESTS PASSED SUCCESSFULLY!');
} catch (error) {
console.error('\n❌ TEST FAILED:', error);
}
}
runTests();

View File

@ -12,6 +12,7 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0", "@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -20,9 +21,12 @@
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0", "pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -34,6 +38,7 @@
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",
@ -457,6 +462,190 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@ -1068,6 +1257,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@ -1137,6 +1336,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -1176,6 +1384,50 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@ -1220,12 +1472,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -1236,6 +1503,18 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1246,6 +1525,26 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": { "node_modules/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -1280,6 +1579,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -1475,6 +1780,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chart.js": { "node_modules/chart.js": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@ -1555,6 +1873,27 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@ -1632,6 +1971,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@ -1649,6 +1994,18 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -1733,6 +2090,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -1779,6 +2145,12 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -1846,6 +2218,15 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1985,6 +2366,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -2131,6 +2527,26 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -2148,6 +2564,22 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2157,6 +2589,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -2322,6 +2763,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -2411,6 +2867,42 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -2430,6 +2922,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2498,6 +2996,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -2550,6 +3054,48 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -2571,6 +3117,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -2620,6 +3175,17 @@
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru.min": { "node_modules/lru.min": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
@ -2643,6 +3209,30 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -2958,6 +3548,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -3016,6 +3618,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3039,6 +3650,38 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@ -3256,6 +3899,12 @@
} }
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proper-lockfile": { "node_modules/proper-lockfile": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@ -3288,6 +3937,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@ -3542,6 +4200,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -3678,6 +4342,12 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@ -3688,6 +4358,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -3856,6 +4538,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici": { "node_modules/undici": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@ -3976,6 +4664,54 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"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"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -13,6 +13,7 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0", "@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -21,9 +22,12 @@
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0", "pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -35,6 +39,7 @@
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",

View File

@ -24,6 +24,19 @@ model Organization {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
branches Branch[]
}
model Branch {
id String @id @default(uuid())
name String
code String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
users User[]
sharedAssets SharedAsset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model User { model User {
@ -36,6 +49,7 @@ model User {
inviteToken String? @unique inviteToken String? @unique
inviteTokenExp DateTime? inviteTokenExp DateTime?
organizationId String? organizationId String?
branchId String?
onboardingStatus String @default("PENDING_ONBOARDING") onboardingStatus String @default("PENDING_ONBOARDING")
partnerGroup String? partnerGroup String?
assignedNdaId String? assignedNdaId String?
@ -49,12 +63,14 @@ model User {
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull) assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull) assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id]) organization Organization? @relation(fields: [organizationId], references: [id])
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
acceptances LegalAcceptance[] acceptances LegalAcceptance[]
auditLogs AuditLog[] auditLogs AuditLog[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
chatSessions ChatSession[]
} }
model Asset { model Asset {
@ -76,24 +92,101 @@ model Asset {
thumbnailUrl String? thumbnailUrl String?
problemStatement String? @db.Text problemStatement String? @db.Text
solution String? @db.Text solution String? @db.Text
contentType String?
includeInKnowledgeBase Boolean @default(true)
knowledgeScope String? @default("CATALOG_SHARED")
folderId String? folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
techStacks TechStack[] @relation("AssetTechStacks")
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
sharedWith SharedAsset[] sharedWith SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
assetGroups AssetGroup[] assetGroups AssetGroup[]
embeddings AssetEmbedding[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Vertical {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetVerticals")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TechStack {
id String @id @default(uuid())
name String @unique
slug String @unique
category String // "Languages & Frameworks", "AI & ML", "Data & Backend", "Cloud & Infra"
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetTechStacks")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EngagementType {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetEngagementTypes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ComplianceStandard {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetComplianceStandards")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetNotification {
id String @id @default(uuid())
title String
message String @db.Text
sentBy String
targetOrgIds String[]
assetIds String[]
createdAt DateTime @default(now())
}
model SharedAsset { model SharedAsset {
id String @id @default(uuid()) id String @id @default(uuid())
assetId String assetId String
organizationId String organizationId String
userId String? userId String?
branchId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
branch Branch? @relation(fields: [branchId], references: [id], onDelete: Cascade)
@@unique([assetId, organizationId, userId]) @@unique([assetId, organizationId, userId])
} }
@ -178,6 +271,7 @@ model EcosystemOffering {
logoUrl String? logoUrl String?
mediaUrl String? mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF" mediaType String? // "IMAGE" | "VIDEO" | "GIF"
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0) orderIndex Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -192,8 +286,42 @@ model ContentShowcase {
thumbnailUrl String? thumbnailUrl String?
redirectUrl String? redirectUrl String?
redirectLabel String? @default("Learn More") redirectLabel String? @default("Learn More")
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0) orderIndex Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model AssetEmbedding {
id String @id @default(uuid())
assetId String
chunkIndex Int
chunkType String @default("TEXT") // TEXT, SLIDE, SHEET, PAGE, TRANSCRIPT
sourceMetadata Json? // OKF Standard JSON: { title, location: "Page 4" | "Slide 2" | "Sheet Data", okfCategory }
content String @db.Text
vector String // Vector array string representation for similarity search
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@index([assetId])
}
model ChatSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages ChatMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatMessage {
id String @id @default(uuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sender String // "USER" | "ASSISTANT"
content String @db.Text
citations Json? // Array of OKF standardized source citations
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,148 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from '../src/utils/db';
const INITIAL_VERTICALS = [
{ name: 'Cybersecurity', slug: 'cybersecurity', icon: 'Shield', color: '#ef4444', description: 'OT, Infrastructure & Data Defense' },
{ name: 'AI & ML', slug: 'ai-ml', icon: 'Cpu', color: '#8b5cf6', description: 'Artificial Intelligence & Machine Learning' },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Activity', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'Landmark', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' },
{ name: 'Insurance', slug: 'insurance', icon: 'FileCheck', color: '#3b82f6', description: 'InsurTech, Claims & Underwriting' },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Leaf', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', color: '#06b6d4', description: 'EdTech, LMS & Institutional Tools' },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Factory', color: '#6366f1', description: 'Industrial Automation & Edge IoT' },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', color: '#14b8a6', description: 'Connected Vehicles & Telematics' },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', color: '#d97706', description: 'E-Commerce, Logistics & Smart Retail' },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', color: '#0284c7', description: 'Smart Contracts & Web3 Infrastructure' },
];
function inferContentType(type: string, subcategory?: string | null): string {
const sub = (subcategory || '').toLowerCase().trim();
if (sub === 'case study') return 'case_study';
if (sub === 'showcase') return 'showcase';
if (sub === 'news letter' || sub === 'marketing milestone') return 'newsletter';
if (sub === 'portfolio' || sub === 'company deck' || sub === 'product showcase' || sub === 'rnd innovation') return 'portfolio';
if (sub === 'mvp') return 'mvp';
if (sub === 'workflow automation' || sub === 'worlflow automation') return 'workflow';
if (sub === 'use case') return 'use_case';
if (sub === 'test drive resources') return 'test_drive';
if (type === 'case_study') return 'case_study';
if (type === 'url') return 'showcase';
if (type.includes('pdf') || type.includes('document') || type.includes('word')) return 'document';
if (type.includes('sheet') || type.includes('csv') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.includes('image')) return 'image';
return 'document';
}
function matchVerticalSlugs(title: string, description?: string | null): string[] {
const text = `${title} ${description || ''}`.toLowerCase();
const matched = new Set<string>();
if (/cybersecurity|security|threat|ot |defence|defense|ransomware|audit|hacker|firewall|fpga|compliance|kyc|aml/.test(text)) {
matched.add('cybersecurity');
}
if (/ai|machine learning|resnet|densenet|nlp|chatbot|genai|deep learning|prediction|predictive|forecasting|gpt|n8n|rag|speech/.test(text)) {
matched.add('ai-ml');
}
if (/health|medical|pharma|drug|cancer|hospital|patient|doctor|eye|blood|hematology|x-ray|brain tumor|bio|biotech|wearable|ventilator|ct scan/.test(text)) {
matched.add('healthcare-pharma');
}
if (/bank|fintech|payment|fraud|credit|loan|financial|accounting|cash|revenue|investor|audit|trade|b2b/.test(text)) {
matched.add('finance-banking');
}
if (/insurance|claims|underwriting|insurtech|policy|catastrophe|actuary/.test(text)) {
matched.add('insurance');
}
if (/energy|grid|power|renewable|ev |electric vehicle|utility|utilities|battery|charging|power plant|oms|ems|metering|solar|wind/.test(text)) {
matched.add('energy-utilities');
}
if (/agri|farm|crop|livestock|soil|aqua|pest|aquaponics|harvest|yield/.test(text)) {
matched.add('agriculture');
}
if (/education|student|learning|lms|classroom|exam|academic|textbook|proctoring|university|school/.test(text)) {
matched.add('education');
}
if (/manufactur|industrial|iot|edge|predictive maintenance|digital twin|esp32|ble board|pcb|robotics|factory|plc/.test(text)) {
matched.add('manufacturing-iot');
}
if (/vehicle|automotive|fleet|telematics|adas|car|driving|mobility|maas/.test(text)) {
matched.add('automotive');
}
if (/retail|e-commerce|supply chain|inventory|mall|store|basket|procurement|logistics|fmcg|pos/.test(text)) {
matched.add('retail-supply-chain');
}
if (/blockchain|smart contract|credentialing|traceability/.test(text)) {
matched.add('blockchain');
}
return Array.from(matched);
}
async function main() {
console.log('🚀 Starting Data Normalization & Taxonomy Seeding...');
// 1. Seed Verticals
const verticalMap = new Map<string, string>(); // slug -> id
for (const v of INITIAL_VERTICALS) {
const upserted = await prisma.vertical.upsert({
where: { slug: v.slug },
update: { name: v.name, icon: v.icon, color: v.color, description: v.description },
create: v,
});
verticalMap.set(v.slug, upserted.id);
}
console.log(`${verticalMap.size} Verticals ready.`);
// 2. Fix typos in Subcategories
await prisma.asset.updateMany({
where: { subcategory: { in: ['Showcaase', 'showcase'] } },
data: { subcategory: 'Showcase' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Worlflow Automation' },
data: { subcategory: 'Workflow Automation' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Use Case', categoryId: 'Marketing' },
data: { categoryId: 'Technical' },
});
await prisma.asset.updateMany({
where: { subcategory: 'MVP', categoryId: 'Presentations' },
data: { categoryId: 'Resources' },
});
console.log('✅ Subcategory typos & category alignments fixed.');
// 3. Process all assets
const assets = await prisma.asset.findMany();
let updatedCount = 0;
for (const asset of assets) {
const contentType = inferContentType(asset.type, asset.subcategory);
const matchedSlugs = matchVerticalSlugs(asset.title, asset.description);
const verticalIds = matchedSlugs.map(slug => verticalMap.get(slug)).filter(Boolean) as string[];
await prisma.asset.update({
where: { id: asset.id },
data: {
contentType,
verticals: {
set: verticalIds.map(id => ({ id })),
},
},
});
updatedCount++;
}
console.log(`🎉 Successfully normalized ${updatedCount} assets with content types & vertical tags!`);
}
main()
.catch(err => {
console.error('❌ Migration failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,26 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function seed() {
await prisma.legalDocument.create({
data: {
type: 'NDA',
version: '1.0',
content: 'This is the standard Non-Disclosure Agreement content...',
isActive: true,
}
});
await prisma.legalDocument.create({
data: {
type: 'MSA',
version: '1.0',
content: 'This is the standard Master Services Agreement content...',
isActive: true,
}
});
console.log('Documents seeded.');
}
seed().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -2,6 +2,7 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import prisma from './src/utils/db'; import prisma from './src/utils/db';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { seedFourGroupTaxonomy } from './src/utils/seed-taxonomy';
async function seed() { async function seed() {
console.log('Starting database seeding...'); console.log('Starting database seeding...');
@ -197,6 +198,9 @@ async function seed() {
} }
console.log('Seeded Ecosystem Offerings.'); console.log('Seeded Ecosystem Offerings.');
// 6. Seed 4-Group Asset Taxonomy
await seedFourGroupTaxonomy();
console.log('Seeding completed successfully.'); console.log('Seeding completed successfully.');
} }

View File

@ -16,6 +16,10 @@ import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes'; import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes'; import legalRoutes from './routes/legal.routes';
import ecosystemRoutes from './routes/ecosystem.routes'; import ecosystemRoutes from './routes/ecosystem.routes';
import taxonomyRoutes from './routes/taxonomy.routes';
import notificationRoutes from './routes/notification.routes';
import chatRoutes from './routes/chat.routes';
import branchRoutes from './routes/branch.routes';
import { ensureBucketExists } from './utils/s3'; import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage'; import { originStorage } from './utils/origin-storage';
@ -106,9 +110,13 @@ app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunc
// API Routes // API Routes
app.use('/api/v1/auth', authRoutes); app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes); app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/assets', notificationRoutes);
app.use('/api/v1/organizations', orgRoutes); app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes); app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/ecosystem', ecosystemRoutes); app.use('/api/v1/ecosystem', ecosystemRoutes);
app.use('/api/v1/taxonomy', taxonomyRoutes);
app.use('/api/v1/chat', chatRoutes);
app.use('/api/v1', branchRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => { app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' }); res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -0,0 +1,29 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
import { ChatService } from './services/chat.service';
async function main() {
console.log('[Auto-Indexing] Starting catalog RAG auto-indexing for restored assets...');
const chatService = new ChatService();
const assets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
console.log(`[Auto-Indexing] Found ${assets.length} assets enabled for Knowledge Base.`);
const assetIds = assets.map(a => a.id);
await chatService.autoIndexCatalog(assetIds);
const embeddingCount = await prisma.assetEmbedding.count();
console.log(`[Auto-Indexing] Successfully indexed ${embeddingCount} OKF vector embedding chunks into database.`);
process.exit(0);
}
main().catch((err) => {
console.error('[Auto-Indexing] Failed:', err);
process.exit(1);
});

View File

@ -59,6 +59,19 @@ export class AssetController {
fileUrl = req.body.url; fileUrl = req.body.url;
} }
const parseJsonOrArray = (val: any) => {
if (!val) return undefined;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); } catch { return val.split(',').map((id: string) => id.trim()).filter(Boolean); }
}
return val;
};
let verticalIds = parseJsonOrArray(req.body.verticalIds);
let techStackIds = parseJsonOrArray(req.body.techStackIds);
let engagementTypeIds = parseJsonOrArray(req.body.engagementTypeIds);
let complianceIds = parseJsonOrArray(req.body.complianceIds);
const assetData = { const assetData = {
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'), title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype), type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
@ -75,6 +88,10 @@ export class AssetController {
thumbnailUrl: req.body.thumbnailUrl || null, thumbnailUrl: req.body.thumbnailUrl || null,
problemStatement: req.body.problemStatement || null, problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null, solution: req.body.solution || null,
verticalIds,
techStackIds,
engagementTypeIds,
complianceIds,
shares, shares,
sharedOrgIds: req.body.sharedOrgIds || null, sharedOrgIds: req.body.sharedOrgIds || null,
}; };
@ -87,7 +104,18 @@ export class AssetController {
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => { public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined; const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
const assets = await this.assetService.getAssets(userContext); const filters = {
search: req.query.search as string,
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined,
techStackIds: req.query.techStackIds ? (req.query.techStackIds as string).split(',') : undefined,
engagementTypeIds: req.query.engagementTypeIds ? (req.query.engagementTypeIds as string).split(',') : undefined,
complianceIds: req.query.complianceIds ? (req.query.complianceIds as string).split(',') : undefined,
contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined,
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
sortBy: req.query.sortBy as any,
};
const assets = await this.assetService.getAssets(userContext, filters);
res.status(200).json(assets); res.status(200).json(assets);
} catch(err) { next(err); } } catch(err) { next(err); }
} }

View File

@ -0,0 +1,39 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { BranchService } from '../services/branch.service';
const branchService = new BranchService();
export class BranchController {
public createBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branch = await branchService.createBranch(organizationId, req.body);
res.status(201).json(branch);
} catch (err) { next(err); }
};
public getBranches = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branches = await branchService.getOrganizationBranches(organizationId);
res.status(200).json(branches);
} catch (err) { next(err); }
};
public updateBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const branch = await branchService.updateBranch(id, req.body);
res.status(200).json(branch);
} catch (err) { next(err); }
};
public deleteBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await branchService.deleteBranch(id);
res.status(200).json({ message: 'Branch deleted successfully' });
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,103 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { ChatService } from '../services/chat.service';
import prisma from '../utils/db';
const chatService = new ChatService();
export class ChatController {
public queryChat = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { prompt, sessionId, attachedEntities } = req.body;
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await chatService.processPrompt(userId, prompt || 'Analyze attached workbench items', sessionId, attachedEntities);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
public getHistory = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const sessions = await prisma.chatSession.findMany({
where: { userId },
include: {
messages: {
take: 2,
orderBy: { createdAt: 'asc' }
}
},
orderBy: { updatedAt: 'desc' },
take: 20,
});
res.status(200).json(sessions);
} catch (err) {
next(err);
}
};
public createSession = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.create({
data: { userId }
});
res.status(201).json({ sessionId: session.id, session });
} catch (err) {
next(err);
}
};
public getSessionMessages = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
const { sessionId } = req.params;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.findFirst({
where: { id: sessionId, userId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
});
if (!session) {
return res.status(404).json({ error: 'Chat session not found' });
}
res.status(200).json(session);
} catch (err) {
next(err);
}
};
public syncAssetKnowledge = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetId } = req.params;
await chatService.ingestAssetKnowledge(assetId);
res.status(200).json({ message: 'Asset knowledge re-indexed successfully' });
} catch (err) {
next(err);
}
};
}

View File

@ -0,0 +1,72 @@
import { Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
import { MailService } from '../services/mail.service';
export class NotificationController {
private mailService = new MailService();
public sendAssetAnnouncement = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { title, message, targetOrgIds, assetIds } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'Title and message are required' });
}
let senderEmail = 'admin@tech4biz.com';
if (req.user?.userId) {
const adminUser = await prisma.user.findUnique({ where: { id: req.user.userId }, select: { email: true } });
if (adminUser?.email) senderEmail = adminUser.email;
}
// Log notification record
const notification = await prisma.assetNotification.create({
data: {
title,
message,
sentBy: senderEmail,
targetOrgIds: Array.isArray(targetOrgIds) ? targetOrgIds : ['ALL'],
assetIds: Array.isArray(assetIds) ? assetIds : [],
}
});
// Find recipient users
const userWhere: any = { role: 'PARTNER_USER' };
if (Array.isArray(targetOrgIds) && targetOrgIds.length > 0 && !targetOrgIds.includes('ALL')) {
userWhere.organizationId = { in: targetOrgIds };
}
const partnerUsers = await prisma.user.findMany({
where: userWhere,
select: { email: true }
});
const recipientEmails = partnerUsers.map(u => u.email).filter(Boolean);
// Trigger email notifications asynchronously via mailService
if (recipientEmails.length > 0) {
this.mailService.sendCustomAnnouncement({
recipients: recipientEmails,
subject: `📢 Asset Announcement: ${title}`,
messageBody: message,
}).catch(err => console.error('Failed to dispatch announcement emails:', err));
}
res.status(201).json({
message: 'Notification sent successfully',
recipientsCount: recipientEmails.length,
notification,
});
} catch (err) { next(err); }
};
public getNotificationLogs = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const logs = await prisma.assetNotification.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
res.status(200).json(logs);
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,207 @@
import { Request, Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
export class TaxonomyController {
// Public/Authenticated: Get all active verticals
public getVerticals = async (req: Request, res: Response, next: NextFunction) => {
try {
const verticals = await prisma.vertical.findMany({
where: { isActive: true },
include: {
_count: {
select: { assets: true }
}
},
orderBy: { orderIndex: 'asc' },
});
res.status(200).json(verticals);
} catch (err) { next(err); }
};
// Public/Authenticated: Get taxonomy metadata with real-time asset counts across all 4 groups
public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => {
try {
const [verticals, techStacks, engagementTypes, complianceStandards, assets] = await Promise.all([
prisma.vertical.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.techStack.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.engagementType.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.complianceStandard.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.asset.findMany({
select: {
categoryId: true,
subcategory: true,
contentType: true,
tags: true,
}
})
]);
const categoryCounts: Record<string, number> = {};
const subcategoryCounts: Record<string, number> = {};
const contentTypeCounts: Record<string, number> = {};
const tagCounts: Record<string, number> = {};
assets.forEach(a => {
if (a.categoryId) {
categoryCounts[a.categoryId] = (categoryCounts[a.categoryId] || 0) + 1;
}
if (a.subcategory) {
subcategoryCounts[a.subcategory] = (subcategoryCounts[a.subcategory] || 0) + 1;
}
if (a.contentType) {
contentTypeCounts[a.contentType] = (contentTypeCounts[a.contentType] || 0) + 1;
}
if (Array.isArray(a.tags)) {
a.tags.forEach(t => {
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
});
}
});
res.status(200).json({
verticals,
techStacks,
engagementTypes,
complianceStandards,
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
tags: Object.entries(tagCounts).map(([name, count]) => ({ name, count })),
totalAssets: assets.length,
});
} catch (err) { next(err); }
};
// Admin: Create Vertical
public createVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const vertical = await prisma.vertical.create({
data: { name, slug, icon, description, color }
});
res.status(201).json(vertical);
} catch (err) { next(err); }
};
// Admin: Update Vertical
public updateVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { name, icon, description, color, isActive, orderIndex } = req.body;
const data: any = {};
if (name !== undefined) {
data.name = name;
data.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
}
if (icon !== undefined) data.icon = icon;
if (description !== undefined) data.description = description;
if (color !== undefined) data.color = color;
if (isActive !== undefined) data.isActive = isActive;
if (orderIndex !== undefined) data.orderIndex = orderIndex;
const vertical = await prisma.vertical.update({
where: { id },
data,
});
res.status(200).json(vertical);
} catch (err) { next(err); }
};
// Admin: Delete Vertical
public deleteVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.vertical.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Tech Stack
public createTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, category, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.techStack.create({
data: { name, slug, category: category || 'Languages & Frameworks', icon, description, color: color || '#64748b' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Tech Stack
public deleteTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.techStack.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Engagement Type
public createEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.engagementType.create({
data: { name, slug, icon, description, color: color || '#0284c7' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Engagement Type
public deleteEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.engagementType.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Compliance Standard
public createComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.complianceStandard.create({
data: { name, slug, icon, description, color: color || '#10b981' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Compliance Standard
public deleteComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.complianceStandard.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import { BranchController } from '../controllers/branch.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const branchController = new BranchController();
router.use(authenticate);
router.get('/organizations/:organizationId/branches', branchController.getBranches);
router.post('/organizations/:organizationId/branches', requireRole('ADMIN'), branchController.createBranch);
router.put('/branches/:id', requireRole('ADMIN'), branchController.updateBranch);
router.delete('/branches/:id', requireRole('ADMIN'), branchController.deleteBranch);
export default router;

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { ChatController } from '../controllers/chat.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const chatController = new ChatController();
router.use(authenticate);
router.post('/query', chatController.queryChat);
router.get('/history', chatController.getHistory);
router.post('/sessions', chatController.createSession);
router.get('/sessions/:sessionId', chatController.getSessionMessages);
router.post('/sync/:assetId', requireRole('ADMIN'), chatController.syncAssetKnowledge);
export default router;

View File

@ -0,0 +1,11 @@
import { Router } from 'express';
import { NotificationController } from '../controllers/notification.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new NotificationController();
router.post('/notify', authenticate, requireRole('ADMIN'), controller.sendAssetAnnouncement);
router.get('/logs', authenticate, requireRole('ADMIN'), controller.getNotificationLogs);
export default router;

View File

@ -0,0 +1,24 @@
import { Router } from 'express';
import { TaxonomyController } from '../controllers/taxonomy.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new TaxonomyController();
router.get('/verticals', authenticate, controller.getVerticals);
router.get('/meta', authenticate, controller.getTaxonomyMeta);
router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createVertical);
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical);
router.post('/tech-stacks', authenticate, requireRole('ADMIN'), controller.createTechStack);
router.delete('/tech-stacks/:id', authenticate, requireRole('ADMIN'), controller.deleteTechStack);
router.post('/engagement-types', authenticate, requireRole('ADMIN'), controller.createEngagementType);
router.delete('/engagement-types/:id', authenticate, requireRole('ADMIN'), controller.deleteEngagementType);
router.post('/compliance-standards', authenticate, requireRole('ADMIN'), controller.createComplianceStandard);
router.delete('/compliance-standards/:id', authenticate, requireRole('ADMIN'), controller.deleteComplianceStandard);
export default router;

View File

@ -0,0 +1,209 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
async function seedTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
process.exit(0);
}
seedTaxonomy().catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});

View File

@ -55,7 +55,7 @@ export class AssetService {
} }
public async createAsset(data: any) { public async createAsset(data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data; const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
// Parse tags // Parse tags
let parsedTags: string[] = []; let parsedTags: string[] = [];
@ -69,10 +69,36 @@ export class AssetService {
} }
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
const parsedVerticalIds = parseIds(verticalIds);
const parsedTechStackIds = parseIds(techStackIds);
const parsedEngagementTypeIds = parseIds(engagementTypeIds);
const parsedComplianceIds = parseIds(complianceIds);
const asset = await prisma.asset.create({ const asset = await prisma.asset.create({
data: { data: {
...rest, ...rest,
tags: parsedTags, tags: parsedTags,
...(parsedVerticalIds.length > 0 ? {
verticals: { connect: parsedVerticalIds.map(id => ({ id })) }
} : {}),
...(parsedTechStackIds.length > 0 ? {
techStacks: { connect: parsedTechStackIds.map(id => ({ id })) }
} : {}),
...(parsedEngagementTypeIds.length > 0 ? {
engagementTypes: { connect: parsedEngagementTypeIds.map(id => ({ id })) }
} : {}),
...(parsedComplianceIds.length > 0 ? {
complianceStandards: { connect: parsedComplianceIds.map(id => ({ id })) }
} : {}),
} }
}); });
@ -138,36 +164,27 @@ export class AssetService {
return this.getAssetById(asset.id); return this.getAssetById(asset.id);
} }
public async getAssets(userContext?: { role: string; userId: string }) { public async getAssets(
userContext?: { role: string; userId: string },
filters?: {
search?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
) {
if (!userContext) { if (!userContext) {
return []; return [];
} }
if (userContext.role === 'ADMIN') { const whereClause: any = {};
return await prisma.asset.findMany({
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
},
orderBy: { createdAt: 'desc' }
});
}
if (userContext.role !== 'ADMIN') {
// For clients/partners, find user organization first // For clients/partners, find user organization first
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userContext.userId } where: { id: userContext.userId }
@ -182,9 +199,8 @@ export class AssetService {
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: []; : [];
const whereClause: any = { whereClause.status = 'published';
status: 'published', whereClause.OR = [
OR: [
{ {
sharedWith: { sharedWith: {
some: { some: {
@ -196,8 +212,7 @@ export class AssetService {
} }
} }
} }
] ];
};
if (userGroups.length > 0) { if (userGroups.length > 0) {
whereClause.OR.push({ whereClause.OR.push({
@ -211,10 +226,92 @@ export class AssetService {
} }
}); });
} }
}
// Apply Filter Criteria (Additive AND logic)
const andConditions: any[] = [];
if (filters?.search && filters.search.trim()) {
const q = filters.search.trim();
andConditions.push({
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ subcategory: { contains: q, mode: 'insensitive' } },
{ categoryId: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } },
]
});
}
if (filters?.verticalIds && filters.verticalIds.length > 0) {
andConditions.push({
verticals: {
some: { id: { in: filters.verticalIds } }
}
});
}
if (filters?.techStackIds && filters.techStackIds.length > 0) {
andConditions.push({
techStacks: {
some: { id: { in: filters.techStackIds } }
}
});
}
if (filters?.engagementTypeIds && filters.engagementTypeIds.length > 0) {
andConditions.push({
engagementTypes: {
some: { id: { in: filters.engagementTypeIds } }
}
});
}
if (filters?.complianceIds && filters.complianceIds.length > 0) {
andConditions.push({
complianceStandards: {
some: { id: { in: filters.complianceIds } }
}
});
}
if (filters?.contentTypes && filters.contentTypes.length > 0) {
andConditions.push({
contentType: { in: filters.contentTypes }
});
}
if (filters?.subcategories && filters.subcategories.length > 0) {
andConditions.push({
subcategory: { in: filters.subcategories }
});
}
if (filters?.tags && filters.tags.length > 0) {
andConditions.push({
tags: { hasSome: filters.tags }
});
}
if (andConditions.length > 0) {
whereClause.AND = andConditions;
}
// Order By
let orderBy: any = { createdAt: 'desc' };
if (filters?.sortBy === 'oldest') orderBy = { createdAt: 'asc' };
else if (filters?.sortBy === 'title_asc') orderBy = { title: 'asc' };
else if (filters?.sortBy === 'title_desc') orderBy = { title: 'desc' };
else if (filters?.sortBy === 'type') orderBy = { type: 'asc' };
return await prisma.asset.findMany({ return await prisma.asset.findMany({
where: whereClause, where: whereClause,
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -225,11 +322,15 @@ export class AssetService {
} }
} }
}, },
downloadRequests: { downloadRequests: userContext.role === 'ADMIN' ? {
include: {
user: { select: { id: true, email: true } }
}
} : {
where: { userId: userContext.userId } where: { userId: userContext.userId }
} }
}, },
orderBy: { createdAt: 'desc' } orderBy,
}); });
} }
@ -237,6 +338,10 @@ export class AssetService {
return await prisma.asset.findUnique({ return await prisma.asset.findUnique({
where: { id }, where: { id },
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -259,7 +364,7 @@ export class AssetService {
} }
public async updateAsset(id: string, data: any) { public async updateAsset(id: string, data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data; const { shares, sharedOrgIds, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
const updateData: any = { ...rest }; const updateData: any = { ...rest };
@ -277,6 +382,28 @@ export class AssetService {
updateData.tags = parsedTags; updateData.tags = parsedTags;
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
if (verticalIds !== undefined) {
updateData.verticals = { set: parseIds(verticalIds).map(vid => ({ id: vid })) };
}
if (techStackIds !== undefined) {
updateData.techStacks = { set: parseIds(techStackIds).map(tid => ({ id: tid })) };
}
if (engagementTypeIds !== undefined) {
updateData.engagementTypes = { set: parseIds(engagementTypeIds).map(eid => ({ id: eid })) };
}
if (complianceIds !== undefined) {
updateData.complianceStandards = { set: parseIds(complianceIds).map(cid => ({ id: cid })) };
}
await prisma.asset.update({ await prisma.asset.update({
where: { id }, where: { id },
data: updateData data: updateData

View File

@ -0,0 +1,38 @@
import prisma from '../utils/db';
export class BranchService {
public async createBranch(organizationId: string, data: { name: string; code?: string }) {
return prisma.branch.create({
data: {
name: data.name,
code: data.code || null,
organizationId,
}
});
}
public async getOrganizationBranches(organizationId: string) {
return prisma.branch.findMany({
where: { organizationId },
include: {
_count: {
select: { users: true, sharedAssets: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async updateBranch(id: string, data: { name?: string; code?: string }) {
return prisma.branch.update({
where: { id },
data,
});
}
public async deleteBranch(id: string) {
return prisma.branch.delete({
where: { id }
});
}
}

View File

@ -0,0 +1,705 @@
import prisma from '../utils/db';
import axios from 'axios';
import { ExtractionService, OKFMetadata } from './extraction.service';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export class ChatService {
private extractionService = new ExtractionService();
private gatewayUrl = process.env.LLM_GATEWAY_URL || 'https://llm-devs.tech4biz.org/v1';
private apiKey = process.env.LLM_API_KEY || 'sk-TM-gxAudDVRTY-kq7_enEA';
private modelId = process.env.LLM_MODEL_ID || 'deepseek-chat';
/**
* Process a user chat prompt with RBAC-scoped, unified RAG retrieval across Assets, Showcase Reels, Ecosystem Offerings, and Legal Documents.
*/
public async processPrompt(
userId: string,
prompt: string,
sessionId?: string,
attachedEntities?: Array<{
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}>
) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
sharedAssets: true,
assignedNda: true,
assignedMsa: true,
}
});
if (!user) {
throw new Error('User session not found');
}
const partnerGroup = user.partnerGroup ? user.partnerGroup.trim().toLowerCase() : '';
const isClient = user.role === 'PARTNER_USER';
// 1. Determine accessible catalog asset IDs
let accessibleAssetIds: string[] = [];
if (user.role === 'ADMIN') {
const allAssets = await prisma.asset.findMany({ select: { id: true } });
accessibleAssetIds = allAssets.map(a => a.id);
} else {
const shared = await prisma.sharedAsset.findMany({
where: {
OR: [
{ organizationId: user.organizationId || '' },
{ userId: user.id },
user.branchId ? { branchId: user.branchId } : {},
]
},
select: { assetId: true }
});
const sharedIds = shared.map(s => s.assetId);
let groupIds: string[] = [];
if (partnerGroup) {
const groups = await prisma.assetGroup.findMany({
include: { assets: { select: { id: true } } }
});
const matchedGroup = groups.find(g => g.name.trim().toLowerCase() === partnerGroup);
if (matchedGroup) {
groupIds = matchedGroup.assets.map(a => a.id);
}
}
const publicAssets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
const publicIds = publicAssets.map(a => a.id);
accessibleAssetIds = Array.from(new Set([...sharedIds, ...groupIds, ...publicIds]));
}
// 2. Fetch Embeddings & Data Sources
let embeddingsCount = await prisma.assetEmbedding.count({
where: { assetId: { in: accessibleAssetIds } }
});
if (embeddingsCount === 0 && accessibleAssetIds.length > 0) {
await this.autoIndexCatalog(accessibleAssetIds);
}
const embeddings = await prisma.assetEmbedding.findMany({
where: {
assetId: { in: accessibleAssetIds },
asset: { includeInKnowledgeBase: true }
},
include: {
asset: {
select: {
id: true,
title: true,
type: true,
tags: true,
description: true,
problemStatement: true,
solution: true,
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
}
}
});
// 3. Fetch Featured Content (ContentShowcase) & Ecosystem Offerings
const showcaseItems = await prisma.contentShowcase.findMany({
where: { isActive: true }
});
const ecosystemOfferings = await prisma.ecosystemOffering.findMany({
where: { isActive: true }
});
// 4. Hybrid Search Engine with Normalized Fuzzy Term Matching
const normalizeStr = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
const promptLower = prompt.toLowerCase().trim();
const promptNorm = normalizeStr(prompt);
const promptTerms = promptLower.split(/\s+/).filter(w => w.length > 2);
const promptVector = JSON.parse(this.extractionService.generateEmbedding(prompt));
const citationsMap = new Map<string, CitationItem>();
const contextLines: string[] = [];
const hasAttachedEntities = attachedEntities && attachedEntities.length > 0;
// Explicit Attached Entities Ingestion (Drag-and-Drop AI Workbench)
if (hasAttachedEntities) {
for (const ent of attachedEntities!) {
if (!ent || !ent.id) continue;
try {
if (ent.entityKind === 'ASSET') {
const dbAsset = await prisma.asset.findUnique({
where: { id: ent.id },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
}).catch(() => null);
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Inspected Catalog Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Subcategory: "${dbAsset.subcategory || '-'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}" | Compliance Standards: "${(dbAsset.complianceStandards || []).map(c => c.name).join(', ')}"\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Inspected Catalog Asset',
type: ent.type || 'ASSET',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${ent.title}" | Type: "${ent.type || 'Asset'}" | Description: "${ent.description || 'N/A'}" | Problem Statement: "${ent.problemStatement || 'N/A'}" | Solution Overview: "${ent.solution || 'N/A'}"\n`
);
}
} else if (ent.entityKind === 'SHOWCASE') {
const dbShowcase = await prisma.contentShowcase.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbShowcase) {
citationsMap.set(dbShowcase.id, {
assetId: dbShowcase.id,
title: dbShowcase.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${dbShowcase.title}" | Video URL: "${dbShowcase.youtubeUrl}" | Description:\n${dbShowcase.description || 'Interactive product reel'}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${ent.title}" | Video URL: "${ent.url || ''}" | Description:\n${ent.description || 'Interactive product reel'}\n`
);
}
} else if (ent.entityKind === 'ECOSYSTEM') {
const dbOffering = await prisma.ecosystemOffering.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbOffering) {
citationsMap.set(dbOffering.id, {
assetId: dbOffering.id,
title: dbOffering.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${dbOffering.name}" | Type: "${dbOffering.type}" | Tagline: "${dbOffering.tagline}" | Website URL: "${dbOffering.websiteUrl}" | Key Benefits: ${((dbOffering.benefits as string[]) || []).join('; ')} | Description:\n${dbOffering.description}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${ent.title}" | Description:\n${ent.description || 'Enterprise Ecosystem Offering'}\n`
);
}
} else if (ent.entityKind === 'LEGAL') {
const dbLegal = await prisma.legalDocument.findFirst({
where: { OR: [{ id: ent.id }, { type: ent.title.includes('NDA') ? 'NDA' : 'MSA' }] }
}).catch(() => null);
if (dbLegal) {
citationsMap.set(dbLegal.id, {
assetId: dbLegal.id,
title: ent.title,
location: 'Legal Agreement',
type: 'legal',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED LEGAL AGREEMENT] Type: "${dbLegal.type}" | Version: "${dbLegal.version}" | Content:\n${dbLegal.content}\n`
);
}
}
} catch (err) {
console.error('Failed to parse entity payload in RAG pipeline', err);
}
}
} else {
// ONLY RUN RAG WHEN NO ENTITIES ARE ATTACHED
// A. Match ContentShowcase items (Only on explicit query or exact title match)
const isExplicitShowcaseQuery = promptLower.includes('showcase') || promptLower.includes('video') || promptLower.includes('reel') || promptLower.includes('featured content');
showcaseItems.forEach((sc) => {
const fullText = (sc.title + ' ' + (sc.description || '')).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactTitleMatch = promptLower.includes(sc.title.toLowerCase()) || sc.title.toLowerCase().includes(promptLower);
if (isExactTitleMatch || (isExplicitShowcaseQuery && matchCount >= 2)) {
citationsMap.set(sc.id, {
assetId: sc.id,
title: sc.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[Featured Content Reel] Title: "${sc.title}" | ID: "${sc.id}" | URL: "${sc.youtubeUrl}" | Description:\n${sc.description || 'Interactive product reel'}\n`
);
}
});
// B. Match EcosystemOffering items (Only on explicit query or exact name match)
const isExplicitEcosystemQuery = promptLower.includes('ecosystem') || promptLower.includes('offering') || promptLower.includes('partner product') || promptLower.includes('explore more');
ecosystemOfferings.forEach((eo) => {
const fullText = (eo.name + ' ' + eo.tagline + ' ' + eo.description).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactNameMatch = promptLower.includes(eo.name.toLowerCase()) || eo.name.toLowerCase().includes(promptLower);
if (isExactNameMatch || (isExplicitEcosystemQuery && matchCount >= 2)) {
citationsMap.set(eo.id, {
assetId: eo.id,
title: eo.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[Ecosystem Offering] Name: "${eo.name}" | Type: "${eo.type}" | Tagline: "${eo.tagline}" | Website URL: "${eo.websiteUrl}" | Key Benefits: ${((eo.benefits as string[]) || []).join('; ')} | Description:\n${eo.description}\n`
);
}
});
// C. Direct Catalog Asset Title/Description/Taxonomy Search
const catalogAssets = await prisma.asset.findMany({
where: {
id: { in: accessibleAssetIds },
status: 'published',
},
include: {
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
});
const stopWords = new Set(['there', 'about', 'where', 'which', 'what', 'have', 'with', 'from', 'this', 'that', 'your', 'portal', 'asset', 'assets', 'product', 'item', 'these', 'those', 'please', 'explain', 'tell']);
const keyTerms = promptTerms.filter(t => !stopWords.has(t));
if (keyTerms.length > 0) {
catalogAssets.forEach(a => {
const fullText = (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.verticals || []).map(v => v.name).join(' ') + ' ' + (a.techStacks || []).map(t => t.name).join(' ')).toLowerCase();
let matchCount = 0;
keyTerms.forEach(kt => {
if (fullText.includes(kt)) matchCount++;
});
if (matchCount >= 1) {
const isRecommended = partnerGroup
? a.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(a.id)) {
citationsMap.set(a.id, {
assetId: a.id,
title: a.title,
location: 'Catalog Asset Overview',
type: a.type,
isRecommended,
});
contextLines.push(
`[Direct Catalog Asset Match] Title: "${a.title}" | ID: "${a.id}" | Type: "${a.type}" | Description: "${a.description || ''}" | Problem: "${a.problemStatement || ''}" | Solution: "${a.solution || ''}"\n`
);
}
}
});
}
// D. Hybrid Vector Search (Only if key terms present)
const isPureNavPrompt = promptLower.includes('theme') || promptLower.includes('dark mode') || promptLower.includes('light mode') || promptLower.includes('how to change') || promptLower.includes('appearance');
if (!isPureNavPrompt && keyTerms.length > 0) {
const scoredChunks = embeddings.map(emb => {
let vectorScore = 0;
try {
const vec = JSON.parse(emb.vector) as number[];
vectorScore = promptVector.reduce((acc: number, val: number, i: number) => acc + val * (vec[i] || 0), 0);
} catch {
vectorScore = 0;
}
const chunkText = (emb.content + ' ' + emb.asset.title + ' ' + (emb.asset.description || '')).toLowerCase();
let keywordMatches = 0;
keyTerms.forEach(term => {
if (chunkText.includes(term)) keywordMatches += 1;
});
const hybridScore = vectorScore * 0.5 + (keywordMatches / Math.max(keyTerms.length, 1)) * 0.5;
return { chunk: emb, score: hybridScore, keywordMatches };
}).sort((a, b) => b.score - a.score);
const topAssetChunks = scoredChunks.filter(({ score, keywordMatches }) => score >= 0.35 && keywordMatches >= 1).slice(0, 3);
topAssetChunks.forEach(({ chunk }, idx) => {
const meta = (chunk.sourceMetadata as unknown as OKFMetadata) || {
assetId: chunk.assetId,
assetTitle: chunk.asset.title,
assetType: chunk.asset.type,
location: `Segment ${chunk.chunkIndex + 1}`,
};
const isRecommended = partnerGroup
? chunk.asset.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(chunk.assetId)) {
citationsMap.set(chunk.assetId, {
assetId: chunk.assetId,
title: chunk.asset.title,
location: meta.location,
type: chunk.asset.type,
isRecommended,
});
}
contextLines.push(
`[Catalog Asset ${idx + 1}] Title: "${chunk.asset.title}" | ID: "${chunk.assetId}" | Location: "${meta.location}" | Details:\n${chunk.content}\n`
);
});
}
}
// D. Inject Assigned Legal Documents (NDA / MSA) if prompt asks about NDA / Legal
if (promptLower.includes('nda') || promptLower.includes('msa') || promptLower.includes('agreement') || promptLower.includes('legal') || promptLower.includes('contract')) {
const legals = await prisma.legalDocument.findMany();
legals.forEach((l: { type: string; version: string; content: string }, i: number) => {
contextLines.push(
`[Legal Document ${i + 1}] Type: "${l.type}" | Version: "${l.version}" | Content:\n${l.content.slice(0, 600)}...\n`
);
});
}
const contextBlock = contextLines.length > 0
? contextLines.join('\n---\n')
: 'No specific catalog or showcase snippets needed for this query.';
const portalGuideContext = isClient ? `
Exact Client Portal Layout & Step-by-Step Navigation Guide (${user.email}):
1. Legal Agreements & NDA/MSA Documents:
- Where to find it: Home Dashboard -> Click the "Legal Agreements" card (or navigate directly to /client/agreements).
- Note: There is NO "Legal Agreements" item in the left sidebar menu. Access it exclusively via the Home Dashboard card "Legal Agreements" or direct URL /client/agreements.
- Assigned NDA: ${user.assignedNda ? `Tech4Biz Standard NDA (v${user.assignedNda.version})` : 'Tech4Biz Standard NDA Agreement'} (Status: Active).
- Assigned MSA: ${user.assignedMsa ? `Tech4Biz Standard MSA (v${user.assignedMsa.version})` : 'Tech4Biz Standard MSA Agreement'} (Status: Active).
2. Asset Explorer (/client/assets):
- Where to find it: Left Sidebar menu -> Click "Asset Explorer" (or Home Dashboard -> "Asset Library" card).
- Features: Search shared catalog assets, filter by Taxonomy tags (Industry Verticals, Tech Stacks, Engagement Types, Compliance), view pitch decks, and request downloads.
3. Featured Content Showcase (/client/showcase):
- Where to find it: Left Sidebar menu -> Click "Featured Content".
- Features: Explore interactive video walk-throughs, YouTube/Instagram demo reels, and case studies (including Digital Twin, Diabetic Patient Time Travel, AuRa, Smart Basket, and AI reels).
4. Explore Ecosystem Offerings (/client/ecosystem):
- Where to find it: Left Sidebar menu -> Click "Explore More" (or Home Dashboard -> "Explore More" card).
- Features: Explore ecosystem products & partner integrations (CodeNuk, Cloudtopiaa).
5. Dark/Light Theme & Profile Settings:
- Theme Toggle: Click the Sun/Moon icon located in the bottom area of the left Sidebar under "Appearance".
- Profile Settings: Click "Profile Settings" in the left Sidebar menu to update password or default theme preferences.
` : `
Exact Admin Console Layout Guide for Administrator (${user.email}):
1. Partner Directory (/admin/partners): Left Sidebar -> "Partners".
2. Approvals Queue (/admin/approvals): Left Sidebar -> "Approvals Queue".
3. Legal Templates (/admin/legal): Left Sidebar -> "Legal Templates".
4. Catalog Management (/admin/assets): Left Sidebar -> "Manage Catalog".
5. Ecosystem Manager (/admin/ecosystem): Left Sidebar -> "Ecosystem Manager".
`;
const systemPrompt = `You are Tech4Biz AI Advisor Workbench, the official enterprise assistant for the Channel Partner Portal.
Role & Target Audience:
- User: ${user.email} (${isClient ? 'Client / Partner' : 'Portal Administrator'}).
OUTPUT FORMATTING REQUIREMENTS (CRITICAL FOR QUALITY & SECURITY):
1. **Never Output Database IDs or UUIDs**: Database IDs (UUIDs, hashes, etc.) are strictly confidential internal identifiers. You MUST NEVER output any ID or UUID in your response text to the user.
2. **Strict Guidelines for Suggested Links**:
- **Internal Portal Pages**: If referencing pages inside the portal, you must ONLY use these exact human-accessible relative links:
- Client Portal: \`/client/assets\` (Asset Explorer), \`/client/showcase\` (Featured Content), \`/client/ecosystem\` (Explore More), or \`/client/agreements\` (Legal Agreements).
- Admin Console: \`/admin/assets\` (Manage Catalog), \`/admin/showcase\` (Showcase Manager), \`/admin/ecosystem\` (Ecosystem Manager), or \`/admin/legal\` (Legal Templates).
- **CRITICAL**: Never append database IDs or UUIDs to these paths (e.g., do NOT link to \`/client/assets/123-abc\`). Doing so creates broken pages.
- **External Links**: You may suggest an external resource link (e.g. "[Visit Website](URL)") ONLY if the URL starts with \`http\` or \`https\` and is a public web link (not containing \`localhost\`, \`127.0.0.1\`, or \`/uploads/\`).
- **Fallback**: If no valid link exists, instruct the user to view it via the citation cards below the chat bubble using the **Quick Preview** button or locate it in the catalog.
3. **Multi-Turn Context Awareness**: Maintain full conversational memory. When asked for follow-ups or comparisons of previously mentioned assets, resolve references accurately.
4. **Never Output Concatenated Single-Line Tables**: EVERY Markdown table row MUST be separated by a real newline character (\\n). Never concatenate table rows like "| Col A | Col B | | :--- | :--- |".
5. **Structure Sections Clearly**: Use bold section titles (e.g. "### Summary", "### Key Differences", "### Features & Specifications").
6. **Use Bullet Points for Readability**: When detailing lists of features, target users, or tech stacks, use bulleted lists instead of long unformatted paragraphs.
${hasAttachedEntities ? `
CRITICAL RULES FOR WORKBENCH ATTACHED ENTITIES:
- THE USER HAS EXCLUSIVELY ATTACHED SPECIFIC ENTITIES TO INSPECT: ${attachedEntities?.map(e => `"${e.title}"`).join(', ')}.
- You MUST answer ONLY about the attached items listed under [WORKBENCH ATTACHED ASSET], [WORKBENCH ATTACHED FEATURED REEL], [WORKBENCH ATTACHED ECOSYSTEM OFFERING], or [WORKBENCH ATTACHED LEGAL AGREEMENT].
- Provide a clear, high-impact summary of what these attached items are, their core problem/solution, tech stack, and key features.
- Do NOT list, summarize, or invent any unattached showcase reels (like Digital Twin, Smart Basket, etc.) or unrelated catalog assets! Focus 100% EXCLUSIVELY on the attached items.
` : `
CRITICAL GROUNDING RULES (ZERO HALLUCINATION & STRICT KNOWLEDGE COMPLIANCE):
1. **Rely ONLY on Provided Knowledge Context**: Answer user questions 100% EXCLUSIVELY using the provided Knowledge Context. Do NOT use outside general knowledge or make ungrounded assumptions.
2. **No Invented Demos or Non-Working Links**: If the context does not explicitly list a live demo URL or document file link for an offering (such as CodeNuk, Cloudtopiaa, or Audittrax Labs), state clearly: "You can explore this offering under the 'Explore More' section (/client/ecosystem) or visit their website at [Website URL]." Do NOT invent dummy demo links, raw file paths, or non-working URLs.
3. **If Information is Missing**: If the provided Knowledge Context does not contain the answer, explicitly state: "I don't have detailed information on that in the portal database. Please check the Asset Explorer or contact your account administrator."
`}
Portal Navigation Guidelines:
- STIPULATION: NEVER tell a Client user to look for "Legal Agreements" in the left sidebar. State clearly: "Click the 'Legal Agreements' card on your Home Dashboard or go to /client/agreements".
- STIPULATION: NEVER mention admin console routes (/admin, Partner Directory, Legal Templates) when talking to a Client user.`;
// 0. Fetch Past Conversation History for Session Context & Follow-up Resolution
let pastMessages: { role: string; content: string }[] = [];
let activeSessionId = sessionId;
if (!activeSessionId) {
const session = await prisma.chatSession.create({
data: { userId }
});
activeSessionId = session.id;
} else {
const dbPast = await prisma.chatMessage.findMany({
where: { sessionId: activeSessionId },
orderBy: { createdAt: 'asc' },
take: 12,
});
pastMessages = dbPast.map(m => ({
role: m.sender === 'USER' ? 'user' : 'assistant',
content: m.content
}));
// Extract citations / asset references from recent ASSISTANT messages to handle follow-up queries like "provide more details on this asset"
const recentBotMsgs = dbPast.filter(m => m.sender === 'ASSISTANT' && m.citations);
for (const botMsg of recentBotMsgs) {
const cites = (botMsg.citations as unknown as CitationItem[]) || [];
for (const cite of cites) {
if (cite.assetId && !citationsMap.has(cite.assetId)) {
const dbAsset = await prisma.asset.findUnique({
where: { id: cite.assetId },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
});
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Previously Discussed Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[PREVIOUSLY DISCUSSED ASSET IN CONVERSATION] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}"\n`
);
}
}
}
}
}
// Save current User prompt first to maintain chronological integrity
await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'USER',
content: prompt,
}
});
const messages = [
{ role: 'system', content: systemPrompt },
...pastMessages,
{ role: 'user', content: `Platform Navigation Guide:\n${portalGuideContext}\n\nKnowledge Context:\n${contextBlock}\n\nUser Question: ${prompt}` }
];
// 6. Call DeepSeek LLM Gateway API
let assistantReply = '';
try {
const response = await axios.post(`${this.gatewayUrl}/chat/completions`, {
model: this.modelId,
messages,
temperature: 0.2,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
assistantReply = response.data.choices[0]?.message?.content || 'No response generated from AI Engine.';
} catch (err: any) {
console.error('DeepSeek Gateway Error:', err?.response?.data || err.message);
assistantReply = 'Here is the requested information from your shared catalog:\n\n' + contextBlock;
}
// Strict Citation Filtering: Only include Verified Knowledge Source citations if user explicitly attached entities or asked for asset/resource recommendations
const isGeneralHelpQuery = promptLower.includes('password') ||
promptLower.includes('theme') ||
promptLower.includes('appearance') ||
promptLower.includes('profile') ||
promptLower.includes('how to log') ||
promptLower.includes('how to sign') ||
promptLower.includes('help');
const hasAssetKeywords = promptLower.includes('asset') ||
promptLower.includes('recommend') ||
promptLower.includes('find') ||
promptLower.includes('search') ||
promptLower.includes('show') ||
promptLower.includes('list') ||
promptLower.includes('what are') ||
promptLower.includes('is there') ||
promptLower.includes('are there') ||
promptLower.includes('showcase') ||
promptLower.includes('agreement') ||
promptLower.includes('legal') ||
promptLower.includes('document') ||
promptLower.includes('video') ||
promptLower.includes('reel') ||
promptLower.includes('offering') ||
promptLower.includes('which are') ||
promptLower.includes('tell me more');
const finalCitations = (hasAttachedEntities || (hasAssetKeywords && !isGeneralHelpQuery))
? Array.from(citationsMap.values())
: [];
const botMessage = await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'ASSISTANT',
content: assistantReply,
citations: finalCitations as any,
}
});
return {
sessionId: activeSessionId,
message: botMessage,
citations: finalCitations,
};
}
/**
* Auto-indexes active catalog assets into AssetEmbedding for instant knowledge retrieval.
*/
public async autoIndexCatalog(assetIds: string[]) {
const assets = await prisma.asset.findMany({
where: {
id: { in: assetIds },
includeInKnowledgeBase: true,
}
});
for (const asset of assets) {
await this.ingestAssetKnowledge(asset.id);
}
}
/**
* Ingest or re-index an asset's text into vector embeddings.
*/
public async ingestAssetKnowledge(assetId: string) {
const asset = await prisma.asset.findUnique({
where: { id: assetId }
});
if (!asset) return;
await prisma.assetEmbedding.deleteMany({
where: { assetId }
});
if (!asset.includeInKnowledgeBase) return;
const okfChunks = await this.extractionService.extractOKFChunks(asset);
for (const chunk of okfChunks) {
const vector = this.extractionService.generateEmbedding(chunk.content);
await prisma.assetEmbedding.create({
data: {
assetId,
chunkIndex: chunk.chunkIndex,
chunkType: chunk.chunkType,
sourceMetadata: chunk.sourceMetadata as any,
content: chunk.content,
vector,
}
});
}
}
}

View File

@ -0,0 +1,310 @@
import fs from 'fs';
import path from 'path';
const _pdfParse = require('pdf-parse');
const pdfParse = _pdfParse.PDFParse || _pdfParse.default || _pdfParse;
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as cheerio from 'cheerio';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import { ScraperService } from './scraper.service';
export interface OKFMetadata {
assetId: string;
assetTitle: string;
assetType: string;
location: string; // e.g. "Page 3", "Slide 5", "Sheet: Financials", "Transcript", "Section: Architecture"
okfCategory?: string;
isRecommended?: boolean;
}
export interface OKFChunk {
chunkIndex: number;
chunkType: 'PAGE' | 'SLIDE' | 'SHEET' | 'TRANSCRIPT' | 'TEXT';
content: string;
sourceMetadata: OKFMetadata;
}
export class ExtractionService {
private scraperService = new ScraperService();
/**
* Simple, fast deterministic embedding generator for local RAG vector search.
* Generates a 64-dimensional float vector normalized for cosine similarity.
*/
public generateEmbedding(text: string): string {
const dim = 64;
const vector = new Array(dim).fill(0);
const cleaned = text.toLowerCase().replace(/[^\w\s]/g, '');
const words = cleaned.split(/\s+/).filter(Boolean);
for (let i = 0; i < words.length; i++) {
const word = words[i];
for (let j = 0; j < word.length; j++) {
const charCode = word.charCodeAt(j);
const idx = (charCode + j * 7 + i * 3) % dim;
vector[idx] += 1;
}
}
// L2 Normalize
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0)) || 1;
const normalized = vector.map(v => Number((v / magnitude).toFixed(6)));
return JSON.stringify(normalized);
}
/**
* Extract text chunks from an asset binary or URL using the Open Knowledge Framework (OKF) standard format.
*/
public async extractOKFChunks(asset: {
id: string;
title: string;
type: string;
url: string;
problemStatement?: string | null;
solution?: string | null;
description?: string | null;
}): Promise<OKFChunk[]> {
const chunks: OKFChunk[] = [];
let chunkIndex = 0;
// Base Primary Metadata Chunk (Guarantees 100% indexing for ALL assets including URLs & Documents)
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Asset Title: "${asset.title}". Type: "${asset.type}". Description: "${asset.description || ''}". Problem: "${asset.problemStatement || ''}". Solution: "${asset.solution || ''}".`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Catalog Overview & Metadata',
},
});
// 1. Ingest Problem Statement & Solution metadata if present
if (asset.problemStatement) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Problem Statement for ${asset.title}: ${asset.problemStatement}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Problem Statement',
},
});
}
if (asset.solution) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Solution Overview for ${asset.title}: ${asset.solution}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Proposed Solution',
},
});
}
// 2. URL Assets / YouTube / Web scraper handling
if (asset.type === 'url' || asset.type === 'case_study' || asset.url.startsWith('http')) {
try {
const scraped = await this.scraperService.scrapeCaseStudy(asset.url);
const fullContent = `${scraped.title}. ${scraped.problemStatement || ''} ${scraped.solution || ''}`;
// Split into 500-token chunks
const subChunks = this.splitText(fullContent, 500);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TRANSCRIPT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Web Link Content: Part ${i + 1}`,
},
});
});
} catch {
if (asset.description) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `${asset.title}: ${asset.description}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'URL Asset Metadata',
},
});
}
}
return chunks;
}
// 3. Binary S3 Assets (PDF, Word, Excel, PPTX, Text)
if (asset.url.startsWith('/uploads/')) {
const fileKey = asset.url.replace('/uploads/', '');
let buffer: Buffer;
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
}));
const byteArray = await response.Body?.transformToByteArray();
if (!byteArray) return chunks;
buffer = Buffer.from(byteArray);
} catch (err) {
console.error(`Failed to fetch S3 object ${fileKey} for extraction:`, err);
return chunks;
}
const ext = path.extname(fileKey).toLowerCase();
// A. PDF Files
if (ext === '.pdf' || asset.type.includes('pdf')) {
try {
let pdfText = '';
try {
const parser = new pdfParse({ data: buffer });
const res = await parser.getText();
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e1) {
try {
const res = await pdfParse(buffer);
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e2) {}
}
if (pdfText) {
const subChunks = this.splitText(pdfText, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'PAGE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PDF Document: Page ${i + 1}`,
},
});
});
}
} catch (e) { console.error('PDF extraction error:', e); }
}
// B. Word Files (.docx, .doc)
else if (ext === '.docx' || ext === '.doc' || asset.type.includes('word')) {
try {
const docResult = await mammoth.extractRawText({ buffer });
const subChunks = this.splitText(docResult.value, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Word Document: Section ${i + 1}`,
},
});
});
} catch (e) { console.error('Docx extraction error:', e); }
}
// C. Excel & CSV Files (.xlsx, .xls, .csv)
else if (ext === '.xlsx' || ext === '.xls' || ext === '.csv' || asset.type.includes('spreadsheet') || asset.type.includes('csv')) {
try {
const workbook = XLSX.read(buffer, { type: 'buffer' });
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csvText = XLSX.utils.sheet_to_csv(sheet);
if (csvText && csvText.trim()) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SHEET',
content: `Sheet [${sheetName}] Data:\n${csvText.slice(0, 1500)}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Spreadsheet Sheet: ${sheetName}`,
},
});
}
});
} catch (e) { console.error('Excel extraction error:', e); }
}
// D. PowerPoint Presentations (.pptx, .ppt)
else if (ext === '.pptx' || ext === '.ppt' || asset.type.includes('presentation')) {
// Simple text extraction from raw slide XML/strings
const rawString = buffer.toString('utf-8').replace(/[^\x20-\x7E]/g, ' ');
const subChunks = this.splitText(rawString, 600);
subChunks.forEach((text, i) => {
if (text.length > 50) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SLIDE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PowerPoint Presentation: Slide Section ${i + 1}`,
},
});
}
});
}
// E. Plain Text / Markdown
else {
const textContent = buffer.toString('utf-8');
const subChunks = this.splitText(textContent, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Document Text: Segment ${i + 1}`,
},
});
});
}
}
return chunks;
}
private splitText(text: string, maxLen: number): string[] {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (!cleaned) return [];
const words = cleaned.split(' ');
const chunks: string[] = [];
let current = '';
for (const word of words) {
if ((current + ' ' + word).length > maxLen) {
if (current) chunks.push(current.trim());
current = word;
} else {
current += (current ? ' ' : '') + word;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
}

View File

@ -25,6 +25,14 @@ export class MailService {
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173'; const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const inviteUrl = `${origin}/invite?token=${inviteToken}`; const inviteUrl = `${origin}/invite?token=${inviteToken}`;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked real email dispatch to real user/client: ${email}`);
console.log(`[DEV EMAIL SAFEGUARD] Mock Invite URL: ${inviteUrl}`);
return { messageId: 'mock-dev-safeguard-id' };
}
const mailOptions = { const mailOptions = {
from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'), from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'),
to: email, to: email,
@ -66,4 +74,33 @@ export class MailService {
throw err; throw err;
} }
} }
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
if (!options.recipients || options.recipients.length === 0) return;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true' && process.env.NODE_ENV === 'production';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
return;
}
const mailOptions = {
from: process.env.SMTP_FROM || '"Tech4Biz Portal" <noreply@tech4biz.com>',
to: options.recipients.join(', '),
subject: options.subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #ffffff;">
<h2 style="color: #0f172a; margin-top: 0;">${options.subject}</h2>
<div style="font-size: 14px; color: #334155; line-height: 1.6; white-space: pre-wrap; background: #f8fafc; padding: 16px; border-radius: 6px; border: 1px solid #e2e8f0;">${options.messageBody}</div>
<p style="font-size: 12px; color: #64748b; margin-top: 24px;">Sent via Tech4Biz Channel Partner Platform</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
console.log(`[SMTP] Announcement email sent to ${options.recipients.length} recipients.`);
} catch (err) {
console.error('[SMTP ERROR] Failed to send announcement email:', err);
}
}
} }

View File

@ -0,0 +1,212 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './db';
export async function seedFourGroupTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
}
if (require.main === module || (process.argv[1] && process.argv[1].includes('seed-taxonomy'))) {
seedFourGroupTaxonomy()
.then(() => process.exit(0))
.catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});
}

View File

@ -5,6 +5,9 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title> <title>Tech4Biz Client & Admin Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ import {
Globe Globe
} from "lucide-react"; } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { ChatDrawer } from "../../components/ui/ChatDrawer";
export const AdminLayout: React.FC = () => { export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -283,6 +284,7 @@ export const AdminLayout: React.FC = () => {
</div> </div>
</footer> </footer>
</main> </main>
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -4,6 +4,7 @@ import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from '../../hooks/use-auth';
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react'; import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChatDrawer } from '../../components/ui/ChatDrawer';
export const ClientLayout: React.FC = () => { export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -394,6 +395,8 @@ export const ClientLayout: React.FC = () => {
</div> </div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* AI Assistant Chat Drawer */}
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -16,27 +16,27 @@ export const PageLayout: React.FC<PageLayoutProps> = ({
className = '', className = '',
}) => { }) => {
return ( return (
<div className={`flex flex-col h-[calc(100vh-140px)] md:h-[calc(100vh-160px)] w-full overflow-hidden ${className}`}> <div className={`flex-1 flex flex-col min-h-0 h-full w-full overflow-hidden ${className}`}>
{/* Page Header (Fixed) */} {/* Page Header (Fixed) */}
<div className="shrink-0 mb-4"> <div className="shrink-0 mb-3 sm:mb-4">
{header} {header}
</div> </div>
{/* Toolbar (Fixed) */} {/* Toolbar (Fixed) */}
{toolbar && ( {toolbar && (
<div className="shrink-0 mb-4"> <div className="shrink-0 mb-3 sm:mb-4">
{toolbar} {toolbar}
</div> </div>
)} )}
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-xl shadow-sm relative flex flex-col scrollbar-thin"> <div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-2xl shadow-sm relative flex flex-col scrollbar-thin">
{children} {children}
</div> </div>
{/* Page-level Footer/Pagination (Fixed) */} {/* Page-level Footer/Pagination (Fixed) */}
{footer && ( {footer && (
<div className="shrink-0 mt-4"> <div className="shrink-0 mt-3 sm:mt-4">
{footer} {footer}
</div> </div>
)} )}

View File

@ -0,0 +1,687 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, X, Send, Bot, User as UserIcon, FileText, Minimize2, Maximize2, RefreshCw, Compass, Eye, Plus, History, MessageSquare } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { useAuthStore } from '../../hooks/use-auth';
import { AssetViewerModal } from '../../features/assets/components/AssetViewerModal';
import MarkdownViewer from './MarkdownViewer';
import type { Asset } from '../../types/assets';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export interface ChatMessage {
id?: string;
sender: 'USER' | 'ASSISTANT';
content: string;
citations?: CitationItem[];
createdAt?: string;
}
export interface ChatSessionItem {
id: string;
createdAt: string;
updatedAt: string;
messages?: ChatMessage[];
}
export interface AttachedEntity {
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}
export const ChatDrawer: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [drawerSize, setDrawerSize] = useState<'standard' | 'wide' | 'maximized'>('standard');
const [showHistory, setShowHistory] = useState(false);
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pastSessions, setPastSessions] = useState<ChatSessionItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [attachedEntities, setAttachedEntities] = useState<AttachedEntity[]>([]);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
sender: 'ASSISTANT',
content: 'Hello! I am your **Tech4Biz AI Advisor Workbench**. **Drag and drop any asset, case study reel, ecosystem offering** here to inspect, summarize, and receive instant role-tailored explanations!',
}
]);
const [activePreviewAsset, setActivePreviewAsset] = useState<Asset | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [hasActiveOverlay, setHasActiveOverlay] = useState(false);
useEffect(() => {
const checkOverlays = () => {
const overlays = document.querySelectorAll('.fixed.inset-0.z-50, .fixed.inset-y-0.right-0');
setHasActiveOverlay(overlays.length > 0);
};
checkOverlays();
const interval = setInterval(checkOverlays, 300);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (isOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isOpen]);
// Listen for custom attach event from card button clicks
useEffect(() => {
const handleAttachEvent = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail) {
const entity = customEvent.detail as AttachedEntity;
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
};
window.addEventListener('attach-ai-entity', handleAttachEvent);
return () => window.removeEventListener('attach-ai-entity', handleAttachEvent);
}, []);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
const entity = JSON.parse(jsonStr) as AttachedEntity;
if (entity.id && entity.title) {
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
} catch (err) {
console.error('Failed to parse dropped entity payload', err);
}
}
};
// Load user session history when history drawer is opened
const fetchSessionHistory = async () => {
setLoadingHistory(true);
try {
const res = await axiosInstance.get('/chat/history');
setPastSessions(res.data || []);
} catch (err) {
console.error('Failed to fetch chat history:', err);
} finally {
setLoadingHistory(false);
}
};
const handleToggleHistory = () => {
if (!showHistory) {
fetchSessionHistory();
}
setShowHistory(!showHistory);
};
const handleStartNewChat = async () => {
setLoading(true);
try {
const res = await axiosInstance.post('/chat/sessions');
setSessionId(res.data.sessionId);
setMessages([
{
sender: 'ASSISTANT',
content: 'Started a new session! **Drag & Drop** any catalog asset, case study, or ecosystem offering below for instant AI analysis.',
}
]);
setShowHistory(false);
setAttachedEntities([]);
} catch (err) {
console.error('Failed to create new session', err);
} finally {
setLoading(false);
}
};
const handleLoadSession = async (sessId: string) => {
setLoading(true);
try {
const res = await axiosInstance.get(`/chat/sessions/${sessId}`);
setSessionId(sessId);
const loadedMessages = (res.data.messages || []).map((m: any) => ({
id: m.id,
sender: m.sender,
content: m.content,
citations: m.citations || [],
createdAt: m.createdAt,
}));
setMessages(loadedMessages.length > 0 ? loadedMessages : [
{
sender: 'ASSISTANT',
content: 'Loaded past session. Ask your follow up questions below!',
}
]);
setShowHistory(false);
} catch (err) {
console.error('Failed to load session', err);
} finally {
setLoading(false);
}
};
const handleSend = async (customPrompt?: string) => {
const textToSend = customPrompt || prompt;
if ((!textToSend.trim() && attachedEntities.length === 0) || loading) return;
const effectiveText = textToSend.trim() || `Explain and analyze the attached ${attachedEntities.length} dropped entity/entities in detail.`;
const userMsg: ChatMessage = {
sender: 'USER',
content: effectiveText + (attachedEntities.length > 0 ? `\n\n📌 *Attached Items:* ${attachedEntities.map(e => e.title).join(', ')}` : ''),
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
if (!customPrompt) setPrompt('');
const currentAttached = [...attachedEntities];
setAttachedEntities([]);
setLoading(true);
try {
const response = await axiosInstance.post('/chat/query', {
prompt: effectiveText,
sessionId,
attachedEntities: currentAttached,
});
const { sessionId: newSessionId, message } = response.data;
setSessionId(newSessionId);
setMessages(prev => [...prev, {
id: message.id,
sender: 'ASSISTANT',
content: message.content,
citations: message.citations || [],
createdAt: message.createdAt,
}]);
} catch (err: any) {
console.error('Chat API Error:', err);
setMessages(prev => [...prev, {
sender: 'ASSISTANT',
content: 'Sorry, I encountered an issue connecting to the AI Gateway. Please try again.',
}]);
} finally {
setLoading(false);
}
};
const openAssetPreview = async (cite: CitationItem) => {
try {
if (cite.location === 'Featured Content Showcase') {
const showcaseRes = await axiosInstance.get('/showcase');
const showcaseItem = showcaseRes.data.find((item: any) => item.id === cite.assetId || item.title === cite.title);
if (showcaseItem) {
setActivePreviewAsset({
id: showcaseItem.id,
title: showcaseItem.title,
type: 'case_study',
url: showcaseItem.youtubeUrl || showcaseItem.mediaUrl || 'https://www.youtube.com/watch?v=svJDGYlQLYw',
description: showcaseItem.description || `Verified Showcase Reel: ${showcaseItem.title}`,
isDownloadable: false,
createdAt: showcaseItem.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
return;
}
}
const res = await axiosInstance.get(`/assets/${cite.assetId}`);
setActivePreviewAsset(res.data);
} catch (err) {
console.error('Failed to fetch asset from API, constructing fallback preview:', err);
let videoUrl = '';
if (cite.title.toLowerCase().includes('digital twin') || cite.title.toLowerCase().includes('digitaltwin')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
} else if (cite.title.toLowerCase().includes('surveillance')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
}
setActivePreviewAsset({
id: cite.assetId,
title: cite.title,
type: cite.type || 'case_study',
url: videoUrl,
description: `Verified Document Source: ${cite.title} (${cite.location})`,
isDownloadable: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
}
};
const handleGoToAsset = (cite: CitationItem) => {
setIsMinimized(true);
const typeLower = (cite.type || '').toLowerCase();
const titleLower = (cite.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
navigate(isCurrentAdmin ? '/admin/showcase' : '/client/showcase', { state: { highlightAssetId: cite.assetId } });
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
navigate(isCurrentAdmin ? '/admin/legal' : '/client/agreements', { state: { highlightAssetId: cite.assetId } });
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
navigate(isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem', { state: { highlightAssetId: cite.assetId } });
} else {
navigate(isCurrentAdmin ? '/admin/assets' : '/client/assets', { state: { highlightAssetId: cite.assetId } });
}
};
const cycleSize = () => {
if (drawerSize === 'standard') setDrawerSize('wide');
else if (drawerSize === 'wide') setDrawerSize('maximized');
else setDrawerSize('standard');
};
const getDimensions = () => {
if (isMinimized) return { width: 'min(380px, 94vw)', height: '56px' };
if (drawerSize === 'maximized') return { width: 'min(880px, 96vw)', height: 'min(84vh, 900px)' };
if (drawerSize === 'wide') return { width: 'min(660px, 95vw)', height: 'min(700px, 82vh)' };
return { width: 'min(440px, 95vw)', height: 'min(600px, 80vh)' };
};
const dimensions = getDimensions();
return (
<>
{/* Draggable Floating Trigger Pill */}
{!isOpen && !hasActiveOverlay && (
<motion.button
drag
dragConstraints={{ left: -1200, right: 20, top: -800, bottom: 20 }}
dragElastic={0.1}
dragMomentum={false}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => { setIsOpen(true); setIsMinimized(false); }}
className="fixed bottom-14 right-6 sm:bottom-16 sm:right-8 z-30 flex items-center gap-2.5 px-4 py-3 bg-slate-900 text-white rounded-full shadow-2xl border border-slate-700/80 hover:border-emerald-500/80 transition-all cursor-grab active:cursor-grabbing group select-none"
>
<div className="p-1.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 group-hover:scale-110 transition-transform">
<Bot className="w-4 h-4" />
</div>
<span className="text-xs font-extrabold tracking-wide font-sans">
AI Advisor
</span>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</motion.button>
)}
{/* Floating Glassmorphic Drawer Window */}
<AnimatePresence>
{isOpen && (
<motion.div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
height: dimensions.height,
width: dimensions.width
}}
exit={{ opacity: 0, y: 40, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="fixed sm:bottom-6 sm:right-6 bottom-0 right-0 left-0 sm:left-auto z-50 bg-slate-950/95 backdrop-blur-2xl border border-slate-800 sm:rounded-3xl rounded-t-2xl shadow-2xl flex flex-col overflow-hidden text-slate-100 font-sans max-w-full"
>
{/* Window Header (Clean Layout) */}
<div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-xs text-white">Tech4Biz AI Advisor</h3>
<p className="text-[10px] text-slate-400">Enterprise Intelligent Assistant</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* + New Chat Button */}
{!isMinimized && (
<button
type="button"
onClick={handleStartNewChat}
title="Start New Chat Session"
className="p-1.5 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[10px] font-bold flex items-center gap-1 transition-all cursor-pointer mr-1"
>
<Plus className="w-3.5 h-3.5" />
<span className="hidden sm:inline">New Chat</span>
</button>
)}
{/* Session History Toggle Button */}
{!isMinimized && (
<button
type="button"
onClick={handleToggleHistory}
title="Past Chat Sessions"
className={`p-1.5 rounded-lg border transition-colors cursor-pointer ${showHistory
? 'bg-emerald-500/30 text-emerald-300 border-emerald-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-800 border-slate-800'
}`}
>
<History className="w-3.5 h-3.5" />
</button>
)}
{/* Resize Drawer Cycle Button */}
{!isMinimized && (
<button
onClick={cycleSize}
title={`Current size: ${drawerSize.toUpperCase()}. Click to toggle window width.`}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{drawerSize === 'maximized' ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
)}
{/* Minimize Button */}
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{isMinimized ? <Maximize2 className="w-3.5 h-3.5" /> : <Minimize2 className="w-3.5 h-3.5" />}
</button>
{/* Close Button */}
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Past Session History Drawer Side Panel */}
{showHistory && !isMinimized && (
<div className="bg-slate-900 border-b border-slate-800 p-3 max-h-48 overflow-y-auto space-y-2 shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
<span>Saved Past Sessions</span>
<span>{pastSessions.length} sessions</span>
</div>
{loadingHistory ? (
<div className="text-center p-3 text-xs text-slate-400 italic">
Loading chat history...
</div>
) : pastSessions.length === 0 ? (
<div className="text-center p-3 text-xs text-slate-500 italic">
No past sessions found. Start a conversation!
</div>
) : (
pastSessions.map((sess) => {
const firstUserMsg = sess.messages?.find(m => m.sender === 'USER')?.content || 'Chat Session';
const isActive = sess.id === sessionId;
return (
<div
key={sess.id}
onClick={() => handleLoadSession(sess.id)}
className={`p-2 rounded-xl border text-xs cursor-pointer flex items-center justify-between transition-all ${isActive
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
: 'bg-slate-950/70 hover:bg-slate-800 text-slate-300 border-slate-800'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="truncate text-[11px] font-medium">{firstUserMsg}</span>
</div>
<span className="text-[9px] font-mono text-slate-500 shrink-0 ml-2">
{new Date(sess.updatedAt || sess.createdAt).toLocaleDateString()}
</span>
</div>
);
})
)}
</div>
)}
{/* Content Body (Visible when not minimized) */}
{!isMinimized && (
<>
{/* Messages Stream */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 text-xs">
{messages.map((msg, i) => (
<div
key={i}
className={`flex gap-2.5 ${msg.sender === 'USER' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`p-1.5 rounded-xl shrink-0 h-fit ${msg.sender === 'USER'
? 'bg-emerald-600 text-white'
: 'bg-slate-900 text-emerald-400 border border-slate-700'
}`}>
{msg.sender === 'USER' ? <UserIcon className="w-3.5 h-3.5" /> : <Bot className="w-3.5 h-3.5" />}
</div>
<div className={`space-y-2 max-w-[85%] ${msg.sender === 'USER' ? 'text-right' : 'text-left'}`}>
<div className={`p-3.5 rounded-2xl leading-relaxed ${msg.sender === 'USER'
? 'bg-emerald-600 text-white font-medium rounded-tr-none whitespace-pre-wrap'
: 'bg-slate-900 text-slate-100 border border-slate-800 rounded-tl-none shadow-md'
}`}>
{msg.sender === 'USER' ? (
msg.content
) : (
<MarkdownViewer markdown={msg.content} variant="dark" />
)}
</div>
{/* Citation Cards with Direct Preview & Location Redirection */}
{msg.citations && msg.citations.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-left">
Verified Knowledge Sources ({msg.citations.length}):
</p>
<div className="space-y-2">
{msg.citations.map((cite) => (
<div
key={cite.assetId}
className="group p-2.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-emerald-500/50 transition-all text-left flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-[11px] text-white group-hover:text-emerald-300 truncate">
{cite.title}
</span>
{cite.isRecommended && (
<span className="px-1.5 py-0.2 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[8px] font-extrabold uppercase shrink-0">
Recommended
</span>
)}
</div>
<span className="text-[9px] text-slate-400 block font-mono">
{cite.location}
</span>
</div>
</div>
</div>
{/* Action Buttons: Preview & Go to Location */}
<div className="flex items-center gap-2 pt-1 border-t border-slate-800">
<button
type="button"
onClick={() => openAssetPreview(cite)}
className="flex-1 py-1 px-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Eye className="w-3 h-3 text-slate-400" />
<span>Quick Preview</span>
</button>
<button
type="button"
onClick={() => handleGoToAsset(cite)}
className="flex-1 py-1 px-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Compass className="w-3 h-3 text-emerald-400" />
<span>Go to Location</span>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex items-center gap-2.5 text-slate-400 text-xs italic p-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin text-emerald-400" />
Analyzing catalog knowledge...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Attached Entities Chip Bar */}
{attachedEntities.length > 0 && (
<div className="px-3 py-2 bg-slate-950 border-t border-slate-800 flex flex-wrap gap-1.5 shrink-0 max-h-28 overflow-y-auto">
<div className="w-full flex items-center justify-between text-[10px] font-extrabold uppercase tracking-wider text-amber-400">
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
Attached Workbench Entities ({attachedEntities.length}):
</span>
<button
onClick={() => setAttachedEntities([])}
className="text-slate-400 hover:text-slate-200 transition-colors text-[9px] cursor-pointer"
>
Clear All
</button>
</div>
{attachedEntities.map(ent => (
<span
key={ent.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-800 text-slate-100 border border-slate-700 shadow-sm"
>
<span className="text-[9px] font-black uppercase px-1.5 py-0.5 rounded bg-amber-500 text-slate-950">
{ent.entityKind}
</span>
<span className="truncate max-w-[160px] text-white">{ent.title}</span>
<button
onClick={() => setAttachedEntities(prev => prev.filter(x => x.id !== ent.id))}
className="hover:text-red-400 transition-colors text-slate-400 cursor-pointer ml-1"
title="Remove attached item"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
{/* Input Controls */}
<div className="p-3 bg-slate-900 border-t border-slate-800 flex gap-2 items-center shrink-0">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={attachedEntities.length > 0 ? "Ask AI to analyze or summarize attached items..." : "Ask AI or drag and drop items here..."}
disabled={loading}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/60 transition-all font-sans"
/>
<button
onClick={() => handleSend()}
disabled={loading || (!prompt.trim() && attachedEntities.length === 0)}
className="p-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 text-slate-950 rounded-xl transition-all font-bold cursor-pointer disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</button>
</div>
{/* Drag and Drop Target Zone Overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 bg-slate-950/90 backdrop-blur-md border-4 border-dashed border-amber-500 rounded-3xl flex flex-col items-center justify-center p-6 text-center animate-pulse">
<Sparkles className="w-12 h-12 text-amber-400 fill-amber-400 mb-3 animate-bounce" />
<h3 className="text-lg font-black text-slate-950 bg-amber-400 px-4 py-1 rounded-full shadow-lg">
Drop Entity Here for Instant AI Workbench Inspection
</h3>
<p className="text-xs font-bold text-slate-300 mt-2 max-w-xs leading-relaxed">
Attach catalog assets, case studies, partner offerings, or legal agreements to analyze and summarize.
</p>
</div>
)}
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Asset Preview Modal Triggered from Citation Click */}
{activePreviewAsset && (
<AssetViewerModal
asset={activePreviewAsset}
isOpen={!!activePreviewAsset}
user={user}
onDownload={() => { }}
onClose={() => setActivePreviewAsset(null)}
/>
)}
</>
);
};

View File

@ -1,16 +1,19 @@
import React from "react"; import React from "react";
interface MarkdownBlock { interface MarkdownBlock {
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p"; type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p" | "table";
content: string; content: string;
language?: string; language?: string;
items?: string[]; items?: string[];
headers?: string[];
rows?: string[][];
} }
interface ParseState { interface ParseState {
blocks: MarkdownBlock[]; blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null; currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null; currentList: { type: "ul" | "ol"; items: string[] } | null;
currentTableLines: string[];
currentParagraphLines: string[]; currentParagraphLines: string[];
} }
@ -41,6 +44,36 @@ const flushList = (state: ParseState): void => {
} }
}; };
const flushTable = (state: ParseState): void => {
if (state.currentTableLines.length > 0) {
const rawLines = state.currentTableLines;
state.currentTableLines = [];
// Filter out separator lines like |---|---|
const parsedRows = rawLines
.filter(line => !/^[|\s-:]+$/.test(line.trim()))
.map(line => {
const cells = line.split('|').map(c => c.trim());
// Remove empty lead/trail cells from leading/trailing pipes
if (cells.length > 0 && cells[0] === '') cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
return cells;
})
.filter(row => row.length > 0);
if (parsedRows.length > 0) {
const headers = parsedRows[0];
const rows = parsedRows.slice(1);
state.blocks.push({
type: "table",
content: "",
headers,
rows,
});
}
}
};
const handleCodeBlock = (trimmed: string, state: ParseState): boolean => { const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith("```")) { if (trimmed.startsWith("```")) {
if (state.currentCodeBlock) { if (state.currentCodeBlock) {
@ -53,6 +86,7 @@ const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
} else { } else {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
const language = trimmed.slice(3).trim(); const language = trimmed.slice(3).trim();
state.currentCodeBlock = { language, lines: [] }; state.currentCodeBlock = { language, lines: [] };
} }
@ -66,6 +100,7 @@ const handleHeading = (line: string, state: ParseState): boolean => {
if (match) { if (match) {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
const level = match[1].length; const level = match[1].length;
state.blocks.push({ state.blocks.push({
type: `h${level}` as any, type: `h${level}` as any,
@ -80,6 +115,7 @@ const handleBlockquote = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith(">")) { if (trimmed.startsWith(">")) {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
state.blocks.push({ state.blocks.push({
type: "blockquote", type: "blockquote",
content: trimmed.replace(/^>\s*/, ""), content: trimmed.replace(/^>\s*/, ""),
@ -93,6 +129,7 @@ const handleLists = (line: string, state: ParseState): boolean => {
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/); const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
if (ulMatch) { if (ulMatch) {
flushParagraph(state); flushParagraph(state);
flushTable(state);
const content = ulMatch[3].trim(); const content = ulMatch[3].trim();
if (state.currentList && state.currentList.type === "ul") { if (state.currentList && state.currentList.type === "ul") {
state.currentList.items.push(content); state.currentList.items.push(content);
@ -106,6 +143,7 @@ const handleLists = (line: string, state: ParseState): boolean => {
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) { if (olMatch) {
flushParagraph(state); flushParagraph(state);
flushTable(state);
const content = olMatch[3].trim(); const content = olMatch[3].trim();
if (state.currentList && state.currentList.type === "ol") { if (state.currentList && state.currentList.type === "ol") {
state.currentList.items.push(content); state.currentList.items.push(content);
@ -119,6 +157,17 @@ const handleLists = (line: string, state: ParseState): boolean => {
return false; return false;
}; };
const handleTableLine = (trimmed: string, state: ParseState): boolean => {
// Check if line contains markdown table pipes
if (trimmed.includes("|") && (trimmed.startsWith("|") || trimmed.includes(" | ") || /^[-|\s:]+$/.test(trimmed))) {
flushParagraph(state);
flushList(state);
state.currentTableLines.push(trimmed);
return true;
}
return false;
};
const handleLine = (line: string, state: ParseState): void => { const handleLine = (line: string, state: ParseState): void => {
const trimmed = line.trim(); const trimmed = line.trim();
@ -134,6 +183,7 @@ const handleLine = (line: string, state: ParseState): void => {
if (trimmed === "---" || trimmed === "***" || trimmed === "___") { if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
state.blocks.push({ type: "hr", content: "" }); state.blocks.push({ type: "hr", content: "" });
return; return;
} }
@ -146,22 +196,73 @@ const handleLine = (line: string, state: ParseState): void => {
return; return;
} }
if (handleTableLine(trimmed, state)) {
return;
}
if (trimmed === "") { if (trimmed === "") {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
return; return;
} }
flushList(state); flushList(state);
flushTable(state);
state.currentParagraphLines.push(line); state.currentParagraphLines.push(line);
}; };
/**
* Preprocesses raw markdown text to split single-line concatenated markdown table rows and merge split table rows across lines.
*/
const sanitizeMarkdownText = (rawText: string): string => {
if (!rawText) return "";
const lines = rawText.split("\n");
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// If the line starts with a pipe but doesn't end with one, it is likely split across linebreaks
if (trimmed.startsWith("|") && !trimmed.endsWith("|")) {
let merged = line;
while (i + 1 < lines.length) {
const nextLine = lines[i + 1];
const nextTrimmed = nextLine.trim();
merged += " " + nextTrimmed;
i++;
if (nextTrimmed.endsWith("|")) {
break;
}
}
processedLines.push(merged);
} else {
processedLines.push(line);
}
}
let formatted = processedLines.join("\n");
// Split inline concatenated table rows like "| Col 1 | Col 2 | | :--- | :--- | | Val 1 | Val 2 |"
formatted = formatted.replace(/\|\s*\|\s*:-/g, "|\n| :-");
formatted = formatted.replace(/\|\s*\|\s*([A-Za-z0-9_*`])/g, "|\n| $1");
formatted = formatted.replace(/([^\n|])\s*(\|[\s\S]+?\|)\s*([^\n|])/g, "$1\n\n$2\n\n$3");
// Clean up repeated linebreaks
formatted = formatted.replace(/\n{3,}/g, "\n\n");
return formatted;
};
export const parseMarkdown = (text: string): MarkdownBlock[] => { export const parseMarkdown = (text: string): MarkdownBlock[] => {
const lines = text.split("\n"); const sanitized = sanitizeMarkdownText(text);
const lines = sanitized.split("\n");
const state: ParseState = { const state: ParseState = {
blocks: [], blocks: [],
currentCodeBlock: null, currentCodeBlock: null,
currentList: null, currentList: null,
currentTableLines: [],
currentParagraphLines: [], currentParagraphLines: [],
}; };
@ -171,6 +272,7 @@ export const parseMarkdown = (text: string): MarkdownBlock[] => {
flushParagraph(state); flushParagraph(state);
flushList(state); flushList(state);
flushTable(state);
return state.blocks; return state.blocks;
}; };
@ -285,7 +387,7 @@ const parseInlineItalic = (tokens: InlineToken[]): InlineToken[] => {
return updated; return updated;
}; };
export const renderInlineText = (text: string): React.ReactNode[] => { export const renderInlineText = (text: string, isDark: boolean = false): React.ReactNode[] => {
if (!text) return []; if (!text) return [];
let tokens: InlineToken[] = [{ type: "text", text }]; let tokens: InlineToken[] = [{ type: "text", text }];
@ -298,11 +400,15 @@ export const renderInlineText = (text: string): React.ReactNode[] => {
return tokens.map((part, idx) => { return tokens.map((part, idx) => {
switch (part.type) { switch (part.type) {
case "bold": case "bold":
return <strong key={idx} className="font-extrabold text-ink-900">{part.text}</strong>; return <strong key={idx} className={isDark ? "font-extrabold text-white" : "font-extrabold text-ink-900"}>{part.text}</strong>;
case "italic": case "italic":
return <em key={idx} className="italic text-ink-800">{part.text}</em>; return <em key={idx} className={isDark ? "italic text-slate-200" : "italic text-ink-800"}>{part.text}</em>;
case "code": case "code":
return <code key={idx} className="bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700">{part.text}</code>; return (
<code key={idx} className={isDark ? "bg-slate-900 border border-slate-700 text-emerald-300 rounded px-1.5 py-0.5 text-xs font-mono" : "bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700"}>
{part.text}
</code>
);
case "link": case "link":
return ( return (
<a <a
@ -310,7 +416,7 @@ export const renderInlineText = (text: string): React.ReactNode[] => {
href={part.url} href={part.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5" className={isDark ? "text-emerald-400 hover:text-emerald-300 font-semibold underline break-all inline-flex items-center gap-0.5" : "text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"}
> >
{part.text} {part.text}
</a> </a>
@ -321,82 +427,121 @@ export const renderInlineText = (text: string): React.ReactNode[] => {
}); });
}; };
const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => { const renderListBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol"; const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul" const listClass = block.type === "ul"
? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-800" ? `list-disc pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`
: "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-800"; : `list-decimal pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`;
return ( return (
<Component key={key} className={listClass}> <Component key={key} className={listClass}>
{block.items?.map((item, idx) => ( {block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item)}</li> <li key={idx}>{renderInlineText(item, isDark)}</li>
))} ))}
</Component> </Component>
); );
}; };
const renderHeadingBlock = (block: MarkdownBlock, key: string): React.ReactNode => { const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const level = block.type.slice(1); const level = block.type.slice(1);
const classes: Record<string, string> = { const classes: Record<string, string> = {
"1": "text-2xl sm:text-3xl font-extrabold text-ink-900 mt-6 mb-3 border-b border-ink-150 pb-2", "1": `text-lg sm:text-xl font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-4 mb-2 border-b border-slate-700/50 pb-1`,
"2": "text-xl sm:text-2xl font-extrabold text-ink-900 mt-5 mb-2.5", "2": `text-base sm:text-lg font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-3 mb-1.5`,
"3": "text-lg sm:text-xl font-bold text-ink-900 mt-4 mb-2", "3": `text-sm sm:text-base font-bold ${isDark ? 'text-emerald-400' : 'text-ink-900'} mt-2.5 mb-1`,
"4": "text-base sm:text-lg font-bold text-ink-800 mt-3 mb-1.5", "4": `text-xs sm:text-sm font-bold ${isDark ? 'text-emerald-300' : 'text-ink-800'} mt-2 mb-1`,
"5": "text-sm sm:text-base font-bold text-ink-800 mt-2.5 mb-1.5", "5": `text-xs font-bold ${isDark ? 'text-slate-200' : 'text-ink-800'} mt-1.5 mb-1`,
"6": "text-xs sm:text-sm font-bold text-ink-700 mt-2 mb-1", "6": `text-xs font-bold ${isDark ? 'text-slate-300' : 'text-ink-700'} mt-1 mb-0.5`,
}; };
const Component = block.type as any; const Component = block.type as any;
return ( return (
<Component key={key} className={classes[level] || ""}> <Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content)} {renderInlineText(block.content, isDark)}
</Component> </Component>
); );
}; };
const renderBlock = (block: MarkdownBlock, index: number): React.ReactNode => { const renderTableBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
if (!block.headers || block.headers.length === 0) return null;
return (
<div key={key} className="my-3 w-full overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/90 shadow-xl max-w-full">
<table className="w-full text-left border-collapse min-w-[320px]">
<thead>
<tr className="bg-slate-900/90 border-b border-slate-800 text-[11px] font-extrabold text-emerald-400 uppercase tracking-wider">
{block.headers.map((h, i) => (
<th key={i} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/80 font-bold whitespace-nowrap">
{renderInlineText(h, isDark)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{(block.rows || []).map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-slate-900/60 transition-colors odd:bg-slate-950/40 even:bg-slate-900/30">
{row.map((cell, cIdx) => (
<td key={cIdx} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/60 text-slate-200 leading-relaxed font-sans">
{renderInlineText(cell, isDark)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
const renderBlock = (block: MarkdownBlock, index: number, isDark: boolean): React.ReactNode => {
const key = `${block.type}-${index}`; const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") { if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key); return renderHeadingBlock(block, key, isDark);
} }
switch (block.type) { switch (block.type) {
case "table":
return renderTableBlock(block, key, isDark);
case "blockquote": case "blockquote":
return ( return (
<blockquote key={key} className="border-l-4 border-primary-500 pl-4 py-1 italic bg-primary-50/20 text-ink-700 my-3 rounded-r-lg"> <blockquote key={key} className={`border-l-4 border-emerald-500 pl-3 py-1 italic ${isDark ? 'bg-slate-900/60 text-slate-200' : 'bg-primary-50/20 text-ink-700'} my-2 rounded-r-lg`}>
{renderInlineText(block.content)} {renderInlineText(block.content, isDark)}
</blockquote> </blockquote>
); );
case "ul": case "ul":
case "ol": case "ol":
return renderListBlock(block, key); return renderListBlock(block, key, isDark);
case "code": case "code":
return ( return (
<div key={key} className="my-4 rounded-xl border border-ink-200 bg-ink-900 text-ink-100 p-4 overflow-x-auto shadow-inner relative group select-text"> <div key={key} className="my-3 rounded-xl border border-slate-800 bg-slate-950 text-slate-100 p-3 overflow-x-auto shadow-inner relative group select-text max-w-full">
{block.language && ( {block.language && (
<div className="absolute right-3 top-3 text-[10px] uppercase font-bold text-ink-400 select-none"> <div className="absolute right-3 top-2 text-[9px] uppercase font-bold text-slate-400 select-none">
{block.language} {block.language}
</div> </div>
)} )}
<pre className="font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto"> <pre className="font-mono text-xs leading-relaxed overflow-x-auto">
{block.content} {block.content}
</pre> </pre>
</div> </div>
); );
case "hr": case "hr":
return <hr key={key} className="border-t border-ink-200 my-6" />; return <hr key={key} className={`my-4 ${isDark ? 'border-slate-800' : 'border-ink-200'}`} />;
default: default:
return <p key={key} className="text-sm text-ink-800 leading-relaxed my-2">{renderInlineText(block.content)}</p>; return (
<p key={key} className={`text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'} leading-relaxed my-1.5`}>
{renderInlineText(block.content, isDark)}
</p>
);
} }
}; };
export interface MarkdownViewerProps { export interface MarkdownViewerProps {
markdown: string; markdown: string;
variant?: 'light' | 'dark' | 'auto';
} }
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown }) => { export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown, variant = 'auto' }) => {
const blocks = parseMarkdown(markdown); const blocks = parseMarkdown(markdown);
const isDark = variant === 'dark';
return ( return (
<div className="w-full text-left select-text font-sans leading-relaxed text-ink-800"> <div className={`w-full text-left select-text font-sans leading-relaxed break-words overflow-hidden ${isDark ? 'text-slate-100' : 'text-ink-800'}`}>
{blocks.map((block, idx) => renderBlock(block, idx))} {blocks.map((block, idx) => renderBlock(block, idx, isDark))}
</div> </div>
); );
}; };

View File

@ -0,0 +1,909 @@
import React, { useState } from 'react';
import { X, Shield, Plus, Trash2, Tag, CheckCircle2, AlertCircle, Bell, Layers, Cpu, ShieldCheck, Send } from 'lucide-react';
import type { TaxonomyMeta, Organization, Asset } from '../../../types/assets';
import {
createVertical, deleteVertical,
createTechStack, deleteTechStack,
createEngagementType, deleteEngagementType,
createComplianceStandard, deleteComplianceStandard,
sendAssetAnnouncement
} from '../../../services/assets-api';
interface AssetAdminManagerModalProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
organizations: Organization[];
allAssets?: Asset[];
onRefreshMeta: () => void;
}
export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
isOpen,
onClose,
meta,
organizations,
allAssets = [],
onRefreshMeta,
}) => {
const [activeTab, setActiveTab] = useState<'verticals' | 'techStacks' | 'engagements' | 'compliance' | 'taxonomy' | 'announcements'>('verticals');
const [loading, setLoading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Inspect filter state for Taxonomy Stats interactive list
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
// Form State: Verticals
const [newVerticalName, setNewVerticalName] = useState('');
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
const [newVerticalDesc, setNewVerticalDesc] = useState('');
// Form State: Tech Stacks
const [newTechName, setNewTechName] = useState('');
const [newTechCategory, setNewTechCategory] = useState('Languages & Frameworks');
const [newTechColor, setNewTechColor] = useState('#64748b');
// Form State: Engagements
const [newEngagementName, setNewEngagementName] = useState('');
const [newEngagementColor, setNewEngagementColor] = useState('#0284c7');
// Form State: Compliance
const [newComplianceName, setNewComplianceName] = useState('');
const [newComplianceColor, setNewComplianceColor] = useState('#10b981');
// Announcement Form State
const [announcementTitle, setAnnouncementTitle] = useState('');
const [announcementMsg, setAnnouncementMsg] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState<string>('ALL');
if (!isOpen) return null;
const matchingAssets = (allAssets || []).filter(asset => {
if (!inspectFilter) return false;
if (inspectFilter.type === 'Subcategory') {
return (asset.subcategory || asset.categoryId || '').toLowerCase() === inspectFilter.name.toLowerCase();
}
if (inspectFilter.type === 'Vertical') {
return asset.verticals?.some(v => v.id === inspectFilter.id || v.name.toLowerCase() === inspectFilter.name.toLowerCase());
}
return false;
});
// Vertical CRUD
const handleCreateVertical = async (e: React.FormEvent) => {
e.preventDefault();
if (!newVerticalName.trim()) return;
setLoading(true);
try {
await createVertical({
name: newVerticalName.trim(),
color: newVerticalColor,
description: newVerticalDesc.trim() || undefined,
icon: 'Shield',
});
setStatusMsg({ type: 'success', text: `Vertical "${newVerticalName}" created successfully!` });
setNewVerticalName('');
setNewVerticalDesc('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create vertical' });
} finally {
setLoading(false);
}
};
const handleDeleteVertical = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete vertical "${name}"?`)) return;
setLoading(true);
try {
await deleteVertical(id);
setStatusMsg({ type: 'success', text: `Vertical "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete vertical' });
} finally {
setLoading(false);
}
};
// Tech Stack CRUD
const handleCreateTechStack = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTechName.trim()) return;
setLoading(true);
try {
await createTechStack({
name: newTechName.trim(),
category: newTechCategory,
color: newTechColor,
});
setStatusMsg({ type: 'success', text: `Tech Stack item "${newTechName}" created successfully!` });
setNewTechName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create tech stack' });
} finally {
setLoading(false);
}
};
const handleDeleteTechStack = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete tech stack item "${name}"?`)) return;
setLoading(true);
try {
await deleteTechStack(id);
setStatusMsg({ type: 'success', text: `Tech Stack item "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete tech stack item' });
} finally {
setLoading(false);
}
};
// Engagement Type CRUD
const handleCreateEngagement = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEngagementName.trim()) return;
setLoading(true);
try {
await createEngagementType({
name: newEngagementName.trim(),
color: newEngagementColor,
});
setStatusMsg({ type: 'success', text: `Engagement Type "${newEngagementName}" created!` });
setNewEngagementName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create engagement type' });
} finally {
setLoading(false);
}
};
const handleDeleteEngagement = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete engagement type "${name}"?`)) return;
setLoading(true);
try {
await deleteEngagementType(id);
setStatusMsg({ type: 'success', text: `Engagement Type "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete engagement type' });
} finally {
setLoading(false);
}
};
// Compliance Standard CRUD
const handleCreateCompliance = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComplianceName.trim()) return;
setLoading(true);
try {
await createComplianceStandard({
name: newComplianceName.trim(),
color: newComplianceColor,
});
setStatusMsg({ type: 'success', text: `Compliance Standard "${newComplianceName}" created!` });
setNewComplianceName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create compliance standard' });
} finally {
setLoading(false);
}
};
const handleDeleteCompliance = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete compliance standard "${name}"?`)) return;
setLoading(true);
try {
await deleteComplianceStandard(id);
setStatusMsg({ type: 'success', text: `Compliance Standard "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete compliance standard' });
} finally {
setLoading(false);
}
};
const handleSendAnnouncement = async (e: React.FormEvent) => {
e.preventDefault();
if (!announcementTitle.trim() || !announcementMsg.trim()) return;
setLoading(true);
try {
await sendAssetAnnouncement({
title: announcementTitle.trim(),
message: announcementMsg.trim(),
targetOrgIds: selectedOrgId === 'ALL' ? ['ALL'] : [selectedOrgId],
});
setStatusMsg({ type: 'success', text: 'Announcement dispatched to partner organizations!' });
setAnnouncementTitle('');
setAnnouncementMsg('');
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to send announcement' });
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 shadow-md">
<Shield className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Taxonomy & Announcements Control Panel
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Manage 4-Group Taxonomy items, metadata stats, and partner notifications
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center overflow-x-auto whitespace-nowrap custom-scrollbar border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-800/50 px-4 scroll-smooth shrink-0">
<button
onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'verticals'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Shield className="w-3.5 h-3.5" />
1. Verticals ({meta?.verticals.length || 0})
</button>
<button
onClick={() => { setActiveTab('techStacks'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'techStacks'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Cpu className="w-3.5 h-3.5" />
2. Tech Stack ({meta?.techStacks?.length || 0})
</button>
<button
onClick={() => { setActiveTab('engagements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'engagements'
? 'border-sky-600 text-sky-600 dark:border-sky-400 dark:text-sky-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Layers className="w-3.5 h-3.5" />
3. Engagements ({meta?.engagementTypes?.length || 0})
</button>
<button
onClick={() => { setActiveTab('compliance'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'compliance'
? 'border-emerald-600 text-emerald-600 dark:border-emerald-400 dark:text-emerald-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
4. Compliance ({meta?.complianceStandards?.length || 0})
</button>
<button
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'taxonomy'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Tag className="w-3.5 h-3.5" />
Stats ({meta?.totalAssets || 0})
</button>
<button
onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'announcements'
? 'border-amber-500 text-amber-500 dark:border-amber-400 dark:text-amber-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Bell className="w-3.5 h-3.5" />
Announcements
</button>
</div>
{/* Status Message */}
{statusMsg && (
<div
className={`px-6 py-3 text-xs font-medium flex items-center gap-2 ${
statusMsg.type === 'success'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
: 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
{statusMsg.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{statusMsg.text}
</div>
)}
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{/* TAB 1: Verticals */}
{activeTab === 'verticals' && (
<div className="space-y-6">
{/* Create Vertical Form */}
<form onSubmit={handleCreateVertical} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Industry Vertical
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Vertical Name
</label>
<input
type="text"
placeholder="e.g. CleanTech & Renewables"
value={newVerticalName}
onChange={e => setNewVerticalName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-9 h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
<input
type="text"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-full px-2 py-1.5 text-xs font-mono rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
/>
</div>
</div>
</div>
<div>
<input
type="text"
placeholder="Short description of this vertical domain..."
value={newVerticalDesc}
onChange={e => setNewVerticalDesc(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newVerticalName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Vertical
</button>
</div>
</form>
{/* Verticals Table */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Verticals ({meta?.verticals.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.verticals || []).map(v => (
<div key={v.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#6366f1' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{v.name}
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{v._count?.assets ?? 0} assets
</span>
</div>
{v.description && <div className="text-[11px] text-slate-400">{v.description}</div>}
</div>
</div>
<button
onClick={() => handleDeleteVertical(v.id, v.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Vertical"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Tech Stacks */}
{activeTab === 'techStacks' && (
<div className="space-y-6">
{/* Create Tech Stack Form */}
<form onSubmit={handleCreateTechStack} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Tech Stack / Capability Item
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Item Name
</label>
<input
type="text"
placeholder="e.g. Rust, PyTorch, GraphQL"
value={newTechName}
onChange={e => setNewTechName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Category / Group
</label>
<select
value={newTechCategory}
onChange={e => setNewTechCategory(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="Languages & Frameworks">Languages & Frameworks</option>
<option value="AI & ML">AI & ML</option>
<option value="Data & Backend">Data & Backend</option>
<option value="Cloud & Infra">Cloud & Infra</option>
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Color
</label>
<input
type="color"
value={newTechColor}
onChange={e => setNewTechColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newTechName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Tech Item
</button>
</div>
</form>
{/* Tech Stack List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Tech Stack Items ({meta?.techStacks?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.techStacks || []).map(t => (
<div key={t.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: t.color || '#64748b' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{t.name}
<span className="text-[10px] font-extrabold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
{t.category}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{t._count?.assets ?? 0} assets
</span>
</div>
</div>
</div>
<button
onClick={() => handleDeleteTechStack(t.id, t.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Tech Item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 3: Engagement Types */}
{activeTab === 'engagements' && (
<div className="space-y-6">
{/* Create Engagement Form */}
<form onSubmit={handleCreateEngagement} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Engagement Type
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Engagement Name
</label>
<input
type="text"
placeholder="e.g. Audit & Advisory, Advisory"
value={newEngagementName}
onChange={e => setNewEngagementName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newEngagementColor}
onChange={e => setNewEngagementColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newEngagementName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Engagement Type
</button>
</div>
</form>
{/* Engagements List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Engagement Types ({meta?.engagementTypes?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: e.color || '#0284c7' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100">{e.name}</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{e._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteEngagement(e.id, e.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Engagement Type"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 4: Compliance Standards */}
{activeTab === 'compliance' && (
<div className="space-y-6">
{/* Create Compliance Form */}
<form onSubmit={handleCreateCompliance} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Compliance Standard / Regulatory Certification
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Compliance Name
</label>
<input
type="text"
placeholder="e.g. ISO 27001, PCI-DSS, FedRAMP"
value={newComplianceName}
onChange={e => setNewComplianceName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newComplianceColor}
onChange={e => setNewComplianceColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newComplianceName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Compliance Standard
</button>
</div>
</form>
{/* Compliance List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Compliance Standards ({meta?.complianceStandards?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: c.color || '#10b981' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{c._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteCompliance(c.id, c.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Compliance Standard"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Taxonomy Stats */}
{activeTab === 'taxonomy' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2.5">
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">Total Assets</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-blue-600">{meta?.verticals.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">1. Verticals</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-purple-600">{meta?.techStacks?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">2. Tech Stacks</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-sky-600">{meta?.engagementTypes?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">3. Engagements</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-emerald-600">{meta?.complianceStandards?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">4. Compliance</div>
</div>
</div>
{/* 1. Industry Verticals Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
1. Industry Verticals Domain Distribution
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{(meta?.verticals || []).map(v => (
<button
key={v.id}
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${
inspectFilter?.name === v.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
<span className="font-bold text-xs">{v.name}</span>
</div>
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-700">
{v._count?.assets ?? 0} assets
</span>
</button>
))}
</div>
</div>
{/* 2. Tech Stack Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
2. Technology Stack & Capabilities
</h4>
<div className="flex flex-wrap gap-1.5">
{(meta?.techStacks || []).map(t => (
<div
key={t.id}
className="px-2.5 py-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-xs font-medium flex items-center gap-1.5"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
<span className="font-semibold">{t.name}</span>
<span className="text-[10px] font-mono opacity-70">({t._count?.assets ?? 0})</span>
</div>
))}
</div>
</div>
{/* 3 & 4. Engagement & Compliance Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
3. Engagement Types
</h4>
<div className="space-y-1.5">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span>{e.name}</span>
<span className="font-mono text-[10px] opacity-70">({e._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
4. Compliance & Regulatory
</h4>
<div className="space-y-1.5">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="font-mono text-[10px] opacity-70">({c._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
</div>
{/* Inspect Filter Asset List Details */}
{inspectFilter && (
<div className="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-extrabold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Tag className="w-4 h-4 text-amber-500" />
Inspecting {inspectFilter.type}: <span className="underline">{inspectFilter.name}</span>
</h5>
<button
onClick={() => setInspectFilter(null)}
className="text-[11px] font-bold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Clear Selection
</button>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-700/60 max-h-48 overflow-y-auto custom-scrollbar bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700">
{matchingAssets.length === 0 ? (
<div className="p-4 text-xs text-center text-slate-500">No assets tagged with this {inspectFilter.type.toLowerCase()} yet.</div>
) : (
matchingAssets.map(asset => (
<div key={asset.id} className="p-2.5 flex items-center justify-between text-xs hover:bg-slate-50 dark:hover:bg-slate-800/50">
<div className="min-w-0 flex-1 pr-3">
<div className="font-bold text-slate-900 dark:text-slate-100 truncate">{asset.title}</div>
<div className="text-[10px] text-slate-500 font-mono truncate">{asset.subcategory || asset.type} {asset.url}</div>
</div>
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="px-2.5 py-1 text-[10px] font-bold bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 hover:bg-slate-200"
>
View Asset
</a>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* TAB 3: Announcements */}
{activeTab === 'announcements' && (
<form onSubmit={handleSendAnnouncement} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Target Partner Organization
</label>
<select
value={selectedOrgId}
onChange={e => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="ALL">All Partner Organizations (Broadcast)</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Announcement Title
</label>
<input
type="text"
placeholder="e.g. New Cybersecurity Case Studies & MVPs Released"
value={announcementTitle}
onChange={e => setAnnouncementTitle(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Message Body
</label>
<textarea
rows={5}
placeholder="Write announcement details for partners..."
value={announcementMsg}
onChange={e => setAnnouncementMsg(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 custom-scrollbar"
/>
</div>
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={loading || !announcementTitle.trim() || !announcementMsg.trim()}
className="px-5 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md disabled:opacity-50 transition-all flex items-center gap-2"
>
<Send className="w-4 h-4" />
Dispatch Announcement
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};

View File

@ -45,7 +45,7 @@ interface AssetCardProps {
onDownload: (asset: Asset) => void; onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void;
isSelected?: boolean; isSelected?: boolean;
onToggleSelect?: (assetId: string) => void; onToggleSelect?: (assetId: string, event?: React.MouseEvent) => void;
isExpanded?: boolean; isExpanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
isRecommended?: boolean; isRecommended?: boolean;
@ -142,9 +142,44 @@ export const AssetCard: React.FC<AssetCardProps> = ({
const hasBanner = isImage || !!asset.thumbnailUrl; const hasBanner = isImage || !!asset.thumbnailUrl;
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url; const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
const handleInspectWithAI = (e: React.MouseEvent) => {
e.stopPropagation();
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
};
return ( return (
<motion.div <motion.div
id={`asset-card-${asset.id}`}
layout layout
draggable={true}
onDragStart={(e: any) => {
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', asset.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }} transition={{ type: "spring", stiffness: 320, damping: 28 }}
onClick={(e) => { onClick={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -153,8 +188,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
} }
onViewDetails(asset); onViewDetails(asset);
}} }}
className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${ className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${isExpanded
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0' ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
: isRecommended : isRecommended
? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5' ? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5'
@ -170,8 +204,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => onToggleSelect(asset.id)} onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm" onChange={(e) => onToggleSelect(asset.id, e as unknown as React.MouseEvent)}
className={`w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
/> />
)} )}
{isRecommended && ( {isRecommended && (
@ -183,10 +219,19 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
<div className="absolute top-2 right-2 z-10 flex items-center gap-1"> <div className="absolute top-2 right-2 z-10 flex items-center gap-1">
<button
onClick={handleInspectWithAI}
className="px-2 py-1 rounded-md bg-ink-900 text-ink-0 hover:bg-ink-800 text-[10px] font-bold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{isRenderable(asset.type, asset.url) && ( {isRenderable(asset.type, asset.url) && (
<button <button
onClick={() => onOpenViewer(asset)} onClick={() => onOpenViewer(asset)}
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer"
title="Preview Online" title="Preview Online"
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
@ -196,7 +241,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<div className="relative"> <div className="relative">
<button <button
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)} onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105" className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer"
> >
<MoreVertical className="w-3.5 h-3.5" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
@ -204,7 +249,17 @@ export const AssetCard: React.FC<AssetCardProps> = ({
{isMenuOpen && ( {isMenuOpen && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} /> <div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="absolute right-0 mt-1.5 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1"> <div className="absolute right-0 mt-1.5 w-48 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
<button
onClick={(e) => {
handleInspectWithAI(e);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-bold text-amber-650 hover:bg-amber-50 flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
Inspect with AI Advisor
</button>
<button <button
onClick={() => { onClick={() => {
onViewDetails(asset); onViewDetails(asset);
@ -297,10 +352,10 @@ export const AssetCard: React.FC<AssetCardProps> = ({
url={getFullAssetUrl(asset.url)} url={getFullAssetUrl(asset.url)}
fallback={ fallback={
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between"> <div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" /> <div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5"> <div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span> <span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-indigo-650">DOCX</span> <span className="font-extrabold text-slate-800">DOCX</span>
</div> </div>
<div className="space-y-2 flex-grow mt-3"> <div className="space-y-2 flex-grow mt-3">
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2"> <h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
@ -380,7 +435,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
{/* Browser Body Web Mockup */} {/* Browser Body Web Mockup */}
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-3 flex flex-col justify-between relative overflow-hidden"> <div className="flex-1 bg-slate-950 p-3 flex flex-col justify-between relative overflow-hidden">
{/* Decorative Grid Pattern */} {/* Decorative Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" /> <div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" />
@ -388,7 +443,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<div className="space-y-2 mt-1.5 relative z-10"> <div className="space-y-2 mt-1.5 relative z-10">
{/* Mock Navbar */} {/* Mock Navbar */}
<div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5"> <div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<div className="w-8 h-1.5 bg-indigo-400/30 rounded" /> <div className="w-8 h-1.5 bg-slate-700 rounded" />
<div className="flex gap-1"> <div className="flex gap-1">
<div className="w-4 h-1 bg-white/20 rounded" /> <div className="w-4 h-1 bg-white/20 rounded" />
<div className="w-4 h-1 bg-white/20 rounded" /> <div className="w-4 h-1 bg-white/20 rounded" />
@ -396,7 +451,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
{/* Mock Page Content */} {/* Mock Page Content */}
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
<div className="h-2.5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded w-3/4" /> <div className="h-2.5 bg-slate-700 rounded w-3/4" />
<div className="h-1.5 bg-white/20 rounded w-11/12" /> <div className="h-1.5 bg-white/20 rounded w-11/12" />
<div className="h-1.5 bg-white/10 rounded w-5/6" /> <div className="h-1.5 bg-white/10 rounded w-5/6" />
</div> </div>
@ -404,7 +459,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
{/* Mock Card Domain Display */} {/* Mock Card Domain Display */}
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none"> <div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none">
<div className="w-6 h-6 rounded bg-indigo-650 flex items-center justify-center text-[10px] font-black text-white shrink-0"> <div className="w-6 h-6 rounded bg-slate-800 flex items-center justify-center text-[10px] font-black text-white shrink-0">
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()} {getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0 text-left"> <div className="flex-1 min-w-0 text-left">
@ -465,12 +520,12 @@ export const AssetCard: React.FC<AssetCardProps> = ({
) : isWord ? ( ) : isWord ? (
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between"> <div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
{/* Document Margins decorative elements */} {/* Document Margins decorative elements */}
<div className="absolute top-0 right-0 left-0 h-1 bg-indigo-600" /> <div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
{/* Word Header */} {/* Word Header */}
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5"> <div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span> <span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-indigo-650">DOCX</span> <span className="font-extrabold text-slate-800">DOCX</span>
</div> </div>
{/* Page Title & Body Text Mockup */} {/* Page Title & Body Text Mockup */}
@ -568,17 +623,14 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
<div className="space-y-1 mb-4 flex-grow flex flex-col"> <div className="space-y-1 mb-4 flex-grow flex flex-col">
<div className="flex items-center gap-1.5 mb-2">
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
{asset.categoryId || 'General'}
</div>
{!asset.isDownloadable && ( {!asset.isDownloadable && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200"> <div className="flex items-center gap-1.5 mb-2 overflow-hidden">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200 shrink-0">
<Lock className="w-2.5 h-2.5" /> <Lock className="w-2.5 h-2.5" />
<span>Strict View Only</span> <span>Strict View Only</span>
</div> </div>
)}
</div> </div>
)}
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}> <h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}>
{asset.title} {asset.title}

View File

@ -113,6 +113,88 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</div> </div>
)} )}
{/* Taxonomy Metadata Section */}
<div className="space-y-3 pt-3 border-t border-ink-200">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">
Taxonomy & Enterprise Classification
</h4>
{/* Verticals */}
{asset.verticals && asset.verticals.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Industry Verticals / Domains:</span>
<div className="flex flex-wrap gap-1.5">
{asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
</div>
</div>
)}
{/* Tech Stacks */}
{asset.techStacks && asset.techStacks.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Tech Stack & Capabilities:</span>
<div className="flex flex-wrap gap-1.5">
{asset.techStacks.map(t => (
<span
key={t.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
{t.name}
</span>
))}
</div>
</div>
)}
{/* Engagement Types */}
{asset.engagementTypes && asset.engagementTypes.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Engagement Type:</span>
<div className="flex flex-wrap gap-1.5">
{asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
</div>
</div>
)}
{/* Compliance Standards */}
{asset.complianceStandards && asset.complianceStandards.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Compliance & Governance:</span>
<div className="flex flex-wrap gap-1.5">
{asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
🛡 {c.name}
</span>
))}
</div>
</div>
)}
</div>
{asset.tags.length > 0 && ( {asset.tags.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>

View File

@ -0,0 +1,224 @@
import React from 'react';
import { Download, ExternalLink, FileText, Globe, Sparkles } from 'lucide-react';
import type { Asset } from '../../../types/assets';
interface AssetTableViewProps {
assets: Asset[];
selectedIds: string[];
recommendedIds?: string[];
onToggleSelect: (id: string, event: React.MouseEvent) => void;
onSelectAll: () => void;
onOpenAsset: (asset: Asset) => void;
onRequestDownload: (id: string) => void;
userRole?: string;
}
export const AssetTableView: React.FC<AssetTableViewProps> = ({
assets,
selectedIds,
recommendedIds = [],
onToggleSelect,
onSelectAll,
onOpenAsset,
onRequestDownload,
}) => {
const allSelected = assets.length > 0 && selectedIds.length === assets.length;
return (
<div className="w-full bg-white dark:bg-slate-900/90 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden transition-all">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
<th className="py-3 px-4 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 focus:ring-slate-400 cursor-pointer"
/>
</th>
<th className="py-3 px-4 min-w-[280px]">Asset Name & Domain</th>
<th className="py-3 px-4 min-w-[140px]">Vertical / Industry</th>
<th className="py-3 px-4 min-w-[130px]">Type / Format</th>
<th className="py-3 px-4 min-w-[120px]">Subcategory</th>
<th className="py-3 px-4 min-w-[120px]">Created</th>
<th className="py-3 px-4 text-right min-w-[110px]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/60 text-xs">
{assets.map((asset) => {
const isSelected = selectedIds.includes(asset.id);
const isRecommended = recommendedIds.includes(asset.id);
return (
<tr
id={`asset-card-${asset.id}`}
key={asset.id}
onClick={(e) => onToggleSelect(asset.id, e)}
className={`group hover:bg-slate-100/70 dark:hover:bg-slate-800/60 transition-colors cursor-pointer ${
isSelected
? 'bg-slate-100 dark:bg-slate-800/80 font-medium'
: isRecommended
? 'bg-amber-500/[0.03] dark:bg-amber-500/[0.04]'
: ''
}`}
>
{/* Selection Checkbox */}
<td className="py-3 px-4" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggleSelect(asset.id, e as any)}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 focus:ring-slate-400 cursor-pointer"
/>
</td>
{/* Title & Description & Recommended Badge */}
<td className="py-3 px-4 min-w-[280px]">
<div className="flex items-start gap-2.5">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 shrink-0 mt-0.5">
{asset.type === 'url' || asset.type === 'case_study' ? (
<Globe className="w-4 h-4 text-slate-500 dark:text-slate-400" />
) : (
<FileText className="w-4 h-4 text-amber-500" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-slate-900 dark:text-slate-100 group-hover:text-slate-900 dark:group-hover:text-white transition-colors truncate">
{asset.title}
</span>
{isRecommended && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 text-[9px] font-extrabold uppercase tracking-wider shrink-0">
<Sparkles className="w-2.5 h-2.5 fill-slate-950" />
<span>Recommended</span>
</span>
)}
</div>
{asset.description && (
<div className="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-1 mt-0.5">
{asset.description}
</div>
)}
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{asset.tags.slice(0, 3).map(t => (
<span key={t} className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">
#{t}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[9px] text-slate-400">+{asset.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
</td>
{/* Taxonomy Classification */}
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1 max-w-xs">
{asset.verticals && asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
{asset.techStacks && asset.techStacks.map(t => (
<span
key={t.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
{t.name}
</span>
))}
{asset.engagementTypes && asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
{asset.complianceStandards && asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
{c.name}
</span>
))}
{(!asset.verticals || asset.verticals.length === 0) &&
(!asset.techStacks || asset.techStacks.length === 0) &&
(!asset.engagementTypes || asset.engagementTypes.length === 0) &&
(!asset.complianceStandards || asset.complianceStandards.length === 0) && (
<span className="text-slate-400 italic text-[11px]">General</span>
)}
</div>
</td>
{/* Type */}
<td className="py-3 px-4">
<span className="inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 capitalize">
{asset.contentType ? asset.contentType.replace('_', ' ') : asset.type}
</span>
</td>
{/* Subcategory */}
<td className="py-3 px-4 text-slate-700 dark:text-slate-300 font-semibold">
{asset.subcategory || asset.categoryId || '—'}
</td>
{/* Created Date */}
<td className="py-3 px-4 text-slate-500 text-[11px]">
{new Date(asset.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</td>
{/* Actions */}
<td className="py-3 px-4 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenAsset(asset)}
className="p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="View Asset"
>
<ExternalLink className="w-4 h-4" />
</button>
{asset.isDownloadable && (
<button
onClick={() => onRequestDownload(asset.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Download Asset"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react'; import { useNavigate } from 'react-router-dom';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
import { axiosInstance } from '../../../services/axios'; import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
@ -23,6 +24,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
user, user,
onDownload onDownload
}) => { }) => {
const navigate = useNavigate();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isLoadingText, setIsLoadingText] = useState(false); const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState(''); const [textPreviewContent, setTextPreviewContent] = useState('');
@ -31,6 +33,29 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
const [isLoadingDoc, setIsLoadingDoc] = useState(false); const [isLoadingDoc, setIsLoadingDoc] = useState(false);
const [docLoadError, setDocLoadError] = useState(false); const [docLoadError, setDocLoadError] = useState(false);
const wordContainerRef = React.useRef<HTMLDivElement>(null); const wordContainerRef = React.useRef<HTMLDivElement>(null);
const extractYouTubeVideoId = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
const getFullAssetUrl = (url: string) => { const getFullAssetUrl = (url: string) => {
let resolvedUrl = url; let resolvedUrl = url;
@ -139,8 +164,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
); );
}; };
const isWord = asset ? (asset.type.includes('word') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc')) : false; const isWord = asset ? (((asset.type || '').includes('word') || (asset.url || '').toLowerCase().endsWith('.docx') || (asset.url || '').toLowerCase().endsWith('.doc'))) : false;
const isSpreadsheet = asset ? (asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv')) : false; const isSpreadsheet = asset ? (((asset.type || '').includes('sheet') || (asset.url || '').toLowerCase().endsWith('.xlsx') || (asset.url || '').toLowerCase().endsWith('.xls') || (asset.url || '').toLowerCase().endsWith('.csv'))) : false;
useEffect(() => { useEffect(() => {
if (isOpen && asset) { if (isOpen && asset) {
@ -238,8 +263,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
} }
}, [docBlob, isWord]); }, [docBlob, isWord]);
const formatBytes = (bytes: number, decimals = 2) => { const formatBytes = (bytes?: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes'; if (!bytes || isNaN(bytes) || bytes === 0) return '';
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
@ -252,6 +277,29 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
onClose(); onClose();
}; };
const handleLocateInCatalog = () => {
onClose();
if (!asset) return;
const typeLower = (asset.type || '').toLowerCase();
const titleLower = (asset.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
let targetBasePath = '';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
targetBasePath = isCurrentAdmin ? '/admin/showcase' : '/client/showcase';
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
targetBasePath = isCurrentAdmin ? '/admin/legal' : '/client/agreements';
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
targetBasePath = isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem';
} else {
targetBasePath = isCurrentAdmin ? '/admin/assets' : '/client/assets';
}
const fullUrl = `${targetBasePath}?highlight=${asset.id}`;
navigate(fullUrl, { state: { highlightAssetId: asset.id } });
};
return ( return (
<Modal <Modal
isOpen={isOpen && !!asset} isOpen={isOpen && !!asset}
@ -261,7 +309,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="text-left"> <div className="text-left">
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span> <span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal"> <span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
{asset?.type === 'url' ? 'External Web Link' : `${asset?.type}${asset ? formatBytes(asset.size) : ''}`} {asset?.type === 'url' ? 'External Web Link' : asset?.size ? `${asset.type}${formatBytes(asset.size)}` : asset?.type}
</span> </span>
</div> </div>
<button <button
@ -312,7 +360,19 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</Button> </Button>
)} )}
{asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && ( {asset && (
<Button
onClick={handleLocateInCatalog}
variant="secondary"
size="sm"
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10"
>
<Compass className="w-4 h-4" />
<span>Locate in Portal Catalog</span>
</Button>
)}
{asset && asset.type !== 'url' && asset.type !== 'case_study' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button <Button
onClick={() => onDownload(asset)} onClick={() => onDownload(asset)}
variant="primary" variant="primary"
@ -386,12 +446,12 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
{/* Browser Content */} {/* Browser Content */}
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-indigo-950 p-6 flex flex-col justify-between relative overflow-hidden"> <div className="flex-1 bg-slate-950 p-6 flex flex-col justify-between relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" /> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" />
<div className="space-y-3 relative z-10 text-left"> <div className="space-y-3 relative z-10 text-left">
<div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5"> <div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5">
<div className="w-16 h-2 bg-indigo-400/40 rounded" /> <div className="w-16 h-2 bg-slate-700 rounded" />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="w-6 h-1.5 bg-white/20 rounded" /> <div className="w-6 h-1.5 bg-white/20 rounded" />
<div className="w-6 h-1.5 bg-white/20 rounded" /> <div className="w-6 h-1.5 bg-white/20 rounded" />
@ -399,14 +459,14 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="h-5 bg-gradient-to-r from-indigo-400 to-cyan-400 rounded-lg w-5/6" /> <div className="h-5 bg-slate-700 rounded-lg w-5/6" />
<div className="h-2 bg-white/20 rounded w-11/12" /> <div className="h-2 bg-white/20 rounded w-11/12" />
<div className="h-2 bg-white/15 rounded w-3/4" /> <div className="h-2 bg-white/15 rounded w-3/4" />
</div> </div>
</div> </div>
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none"> <div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none">
<div className="w-10 h-10 rounded-lg bg-indigo-650 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md shadow-indigo-600/20"> <div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md">
{(() => { {(() => {
try { try {
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase(); return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
@ -437,7 +497,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]"> <div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]">
<div className="space-y-4 text-left"> <div className="space-y-4 text-left">
<div> <div>
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-indigo-650 bg-indigo-50 border border-indigo-100"> <span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-slate-800 bg-slate-100 border border-slate-200">
External Portal Link External Portal Link
</span> </span>
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3> <h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>
@ -495,6 +555,61 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div> </div>
</div> </div>
</div> </div>
) : asset.type === 'case_study' || asset.type.includes('video') || asset.type === 'showcase' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-emerald-600" />
<span className="font-sans">Executive Case Study & Video Showcase</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
</div>
<div className="flex-1 overflow-auto p-6 md:p-8 flex flex-col items-center justify-start gap-6 bg-ink-50">
{asset.url && (asset.url.includes('youtube.com') || asset.url.includes('youtu.be')) ? (
<div className="w-full max-w-3xl aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-ink-300 shrink-0">
<iframe
src={`https://www.youtube-nocookie.com/embed/${extractYouTubeVideoId(asset.url)}?autoplay=1&rel=0`}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<div className="w-full max-w-3xl p-6 bg-slate-900 text-white rounded-2xl shadow-xl border border-slate-800 space-y-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-[10px] font-extrabold uppercase bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Verified Showcase Reel
</span>
</div>
<h2 className="text-lg font-bold">{asset.title}</h2>
{asset.description && <p className="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{asset.description}</p>}
</div>
)}
<div className="w-full max-w-3xl bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-md space-y-4 text-left">
<h3 className="text-base font-extrabold text-ink-900">{asset.title}</h3>
{asset.problemStatement && (
<div className="space-y-1">
<span className="text-xs font-bold text-red-600 uppercase tracking-wider">Problem Statement</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.problemStatement}</p>
</div>
)}
{asset.solution && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Implemented Technical Solution</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.solution}</p>
</div>
)}
{asset.description && !asset.problemStatement && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-ink-600 uppercase tracking-wider">Executive Overview</span>
<p className="text-xs text-ink-700 leading-relaxed whitespace-pre-wrap">{asset.description}</p>
</div>
)}
</div>
</div>
</div>
) : asset.type.includes('pdf') ? ( ) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0"> <div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
@ -691,6 +806,100 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100" className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/> />
</div> </div>
) : (asset.type === 'case_study' || asset.problemStatement || asset.solution) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-4xl mx-auto w-full space-y-6">
{asset.thumbnailUrl && (
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
<img src={asset.thumbnailUrl} alt={asset.title} className="w-full h-full object-cover" />
</div>
)}
<div className="border-b border-ink-200 pb-4">
<div className="flex items-center gap-2 mb-2">
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
Executive Case Study Showcase
</span>
{asset.type && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-ink-100 text-ink-700 capitalize">
{asset.type}
</span>
)}
</div>
<h1 className="text-xl sm:text-2xl font-extrabold text-ink-900 leading-tight">{asset.title}</h1>
{asset.description && <p className="text-xs text-ink-600 mt-2 leading-relaxed">{asset.description}</p>}
</div>
{/* Problem Statement Block */}
{asset.problemStatement && (
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Problem Statement & Challenge
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.problemStatement} />
</div>
</div>
)}
{/* Proposed Solution Block */}
{asset.solution && (
<div className="p-4 rounded-xl bg-emerald-500/5 border border-emerald-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-500" />
Implemented Technical Solution
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.solution} />
</div>
</div>
)}
{asset.url && asset.url.startsWith('http') && (
<div className="pt-2 flex justify-end">
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-ink-900 text-ink-0 hover:bg-ink-800 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Showcase Webpage
</a>
</div>
)}
</div>
</div>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-3xl mx-auto w-full space-y-6 text-center flex flex-col items-center justify-center h-full">
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600">
<Globe className="w-8 h-8" />
</div>
<div>
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-600 border border-blue-500/20">
External Web Resource
</span>
<h2 className="text-xl font-extrabold text-ink-900 mt-3">{asset.title}</h2>
{asset.description && <p className="text-xs text-ink-600 mt-2 max-w-lg mx-auto">{asset.description}</p>}
</div>
<div className="p-4 bg-ink-50 border border-ink-200 rounded-xl w-full max-w-md text-left">
<p className="text-[10px] font-mono text-ink-500 truncate">{asset.url}</p>
</div>
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md transition-all cursor-pointer"
>
<ExternalLink className="w-4 h-4" />
Launch Web Document
</a>
</div>
</div>
) : ( ) : (
<div className="text-center p-8 flex flex-col justify-center items-center"> <div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" /> <File className="w-12 h-12 text-ink-300 mb-3" />

View File

@ -65,7 +65,7 @@ export const DocxThumbnail: React.FC<DocxThumbnailProps> = ({ url, fallback }) =
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none"> <div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none">
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10"> <div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10">
<div className="w-5 h-5 rounded-full border-2 border-indigo-600 border-t-transparent animate-spin" /> <div className="w-5 h-5 rounded-full border-2 border-slate-800 border-t-transparent animate-spin" />
</div> </div>
)} )}
<div <div

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { updateAsset } from '../../../services/assets-api'; import { updateAsset, getTaxonomyMeta } from '../../../services/assets-api';
import type { Asset } from '../../../types/assets'; import type { Asset, TaxonomyMeta } from '../../../types/assets';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast'; import { useToast } from '../../../hooks/use-toast';
@ -19,6 +19,13 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [editVerticalIds, setEditVerticalIds] = useState<string[]>([]);
const [editTechStackIds, setEditTechStackIds] = useState<string[]>([]);
const [editEngagementTypeIds, setEditEngagementTypeIds] = useState<string[]>([]);
const [editComplianceIds, setEditComplianceIds] = useState<string[]>([]);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing'); const [editCategory, setEditCategory] = useState('Marketing');
@ -28,6 +35,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
const [editIsDownloadable, setEditIsDownloadable] = useState(true); const [editIsDownloadable, setEditIsDownloadable] = useState(true);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
setEditTitle(asset.title); setEditTitle(asset.title);
@ -37,9 +48,18 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
setEditTags(asset.tags.join(', ')); setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || ''); setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable); setEditIsDownloadable(asset.isDownloadable);
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
setEditEngagementTypeIds(asset.engagementTypes ? asset.engagementTypes.map(e => e.id) : []);
setEditComplianceIds(asset.complianceStandards ? asset.complianceStandards.map(c => c.id) : []);
} }
}, [asset]); }, [asset]);
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
const handleEditSubmit = async (e: React.FormEvent) => { const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset) return;
@ -51,6 +71,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
description: editDescription, description: editDescription,
categoryId: editCategory, categoryId: editCategory,
subcategory: editSubcategory, subcategory: editSubcategory,
verticalIds: editVerticalIds,
techStackIds: editTechStackIds,
engagementTypeIds: editEngagementTypeIds,
complianceIds: editComplianceIds,
tags: editTags.split(',').map(t => t.trim()).filter(Boolean), tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl, githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable, isDownloadable: editIsDownloadable,
@ -107,30 +131,130 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 4-Group Taxonomy Demarcation Selection */}
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<select 1. Industry Verticals / Domains
value={editCategory} </label>
onChange={(e) => setEditCategory(e.target.value)} <div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900" {(taxonomyMeta?.verticals || []).map(v => {
const isSelected = editVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setEditVerticalIds(toggleSelection(editVerticalIds, v.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
> >
<option value="Marketing">Marketing</option> {v.name}
<option value="Presentations">Presentations</option> </button>
<option value="Branding">Branding</option> );
<option value="Resources">Resources</option> })}
<option value="Technical">Technical</option>
</select>
</div> </div>
</div>
{/* Group 2: Tech Stack */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<input 2. Technology Stack & Capabilities
type="text" </label>
value={editSubcategory} <div className="flex flex-col gap-2 max-h-40 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
onChange={(e) => setEditSubcategory(e.target.value)} {['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
placeholder="e.g. Slide Deck" const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400" if (items.length === 0) return null;
/> return (
<div key={catName} className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{catName}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map(t => {
const isSelected = editTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setEditTechStackIds(toggleSelection(editTechStackIds, t.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Group 3 & Group 4 Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Group 3: Engagement Type */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
3. Engagement Type
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.engagementTypes || []).map(e => {
const isSelected = editEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setEditEngagementTypeIds(toggleSelection(editEngagementTypeIds, e.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{e.name}
</button>
);
})}
</div>
</div>
{/* Group 4: Compliance Standards */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
4. Compliance & Governance
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.complianceStandards || []).map(c => {
const isSelected = editComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setEditComplianceIds(toggleSelection(editComplianceIds, c.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,451 @@
import React, { useState } from 'react';
import { X, Filter, Search, RotateCcw, Check, Shield, Cpu, Activity, Landmark, FileCheck, Zap, Leaf, GraduationCap, Factory, Car, ShoppingBag, Link as LinkIcon, FileText, LayoutGrid, Newspaper, Briefcase, PlayCircle, Code, Compass, HelpCircle } from 'lucide-react';
import type { TaxonomyMeta, AssetQueryFilters } from '../../../types/assets';
interface FilterDrawerProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
filters: AssetQueryFilters;
onChangeFilters: (newFilters: AssetQueryFilters) => void;
onClearAll: () => void;
}
const VERTICAL_ICONS: Record<string, React.ReactNode> = {
Shield: <Shield className="w-4 h-4" />,
Cpu: <Cpu className="w-4 h-4" />,
Activity: <Activity className="w-4 h-4" />,
Landmark: <Landmark className="w-4 h-4" />,
FileCheck: <FileCheck className="w-4 h-4" />,
Zap: <Zap className="w-4 h-4" />,
Leaf: <Leaf className="w-4 h-4" />,
GraduationCap: <GraduationCap className="w-4 h-4" />,
Factory: <Factory className="w-4 h-4" />,
Car: <Car className="w-4 h-4" />,
ShoppingBag: <ShoppingBag className="w-4 h-4" />,
Link: <LinkIcon className="w-4 h-4" />,
};
const CONTENT_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
case_study: { label: 'Case Studies', icon: <FileText className="w-4 h-4" />, color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
showcase: { label: 'Showcases', icon: <LayoutGrid className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border-slate-500/20' },
newsletter: { label: 'Newsletters', icon: <Newspaper className="w-4 h-4" />, color: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
portfolio: { label: 'Portfolios & Decks', icon: <Briefcase className="w-4 h-4" />, color: 'bg-purple-500/10 text-purple-600 border-purple-500/20' },
mvp: { label: 'Live MVPs', icon: <PlayCircle className="w-4 h-4" />, color: 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
document: { label: 'Documents & PDFs', icon: <FileText className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-600 border-slate-500/20' },
workflow: { label: 'Workflow Automations', icon: <Code className="w-4 h-4" />, color: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
use_case: { label: 'Technical Use Cases', icon: <Compass className="w-4 h-4" />, color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
test_drive: { label: 'Test Drive Resources', icon: <HelpCircle className="w-4 h-4" />, color: 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
};
export const FilterDrawer: React.FC<FilterDrawerProps> = ({
isOpen,
onClose,
meta,
filters,
onChangeFilters,
onClearAll,
}) => {
const [filterQuery, setFilterQuery] = useState('');
if (!isOpen) return null;
const toggleArrayItem = (current: string[] | undefined, item: string): string[] => {
const arr = current || [];
return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item];
};
const activeCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const filteredVerticals = (meta?.verticals || []).filter(v =>
v.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredSubcategories = (meta?.subcategories || []).filter(s =>
s.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredTags = (meta?.tags || []).filter(t =>
t.name.toLowerCase().includes(filterQuery.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity duration-300 animate-fadeIn"
onClick={onClose}
/>
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-md bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 flex flex-col transform transition-transform duration-300">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100">
<Filter className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
Asset Discovery Filters
{activeCount > 0 && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{activeCount}
</span>
)}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Narrow catalog by domains, types & tags
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Search inside Filters */}
<div className="p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/30 dark:bg-slate-900/30">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search filter keywords..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
{/* 1. Industry Verticals (Group 1) */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>1. Industry Verticals</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({filteredVerticals.length})
</span>
</h3>
<div className="space-y-1.5">
{filteredVerticals.map(vertical => {
const isSelected = filters.verticalIds?.includes(vertical.id);
const iconNode = vertical.icon ? VERTICAL_ICONS[vertical.icon] : null;
return (
<button
key={vertical.id}
onClick={() =>
onChangeFilters({
...filters,
verticalIds: toggleArrayItem(filters.verticalIds, vertical.id),
})
}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 font-bold shadow-sm'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: vertical.color || '#3b82f6' }}
/>
<span className="shrink-0 opacity-80">
{iconNode}
</span>
<span className="truncate">{vertical.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
isSelected ? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200' : 'bg-slate-100 dark:bg-slate-800 text-slate-500'
}`}>
{vertical._count?.assets ?? 0}
</span>
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
isSelected
? 'bg-amber-500 border-amber-500 text-slate-950'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
}`}
>
{isSelected && <Check className="w-3 h-3 stroke-[3]" />}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* 2. Technology Stack (Group 2) */}
{(meta?.techStacks || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>2. Technology Stack</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({(meta?.techStacks || []).length})
</span>
</h3>
<div className="space-y-3">
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(cat => {
const groupItems = (meta?.techStacks || []).filter(t => t.category === cat);
if (groupItems.length === 0) return null;
return (
<div key={cat} className="space-y-1">
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 px-1">
{cat}
</div>
<div className="flex flex-wrap gap-1.5">
{groupItems.map(tech => {
const isSelected = filters.techStackIds?.includes(tech.id);
return (
<button
key={tech.id}
onClick={() =>
onChangeFilters({
...filters,
techStackIds: toggleArrayItem(filters.techStackIds, tech.id),
})
}
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all flex items-center gap-1.5 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-bold'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: tech.color || '#64748b' }} />
{tech.name}
<span className="text-[10px] opacity-70">({tech._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 3. Engagement Type (Group 3) */}
{(meta?.engagementTypes || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>3. Engagement Type</span>
</h3>
<div className="grid grid-cols-2 gap-2">
{(meta?.engagementTypes || []).map(eng => {
const isSelected = filters.engagementTypeIds?.includes(eng.id);
return (
<button
key={eng.id}
onClick={() =>
onChangeFilters({
...filters,
engagementTypeIds: toggleArrayItem(filters.engagementTypeIds, eng.id),
})
}
className={`p-2.5 rounded-lg border text-left text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="font-bold flex items-center justify-between">
<span>{eng.name}</span>
<span className="text-[10px] opacity-70 font-mono">({eng._count?.assets ?? 0})</span>
</div>
{eng.description && (
<div className="text-[10px] opacity-75 mt-0.5 line-clamp-1">{eng.description}</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* 4. Compliance & Regulatory (Group 4) */}
{(meta?.complianceStandards || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>4. Compliance & Governance</span>
</h3>
<div className="flex flex-wrap gap-1.5">
{(meta?.complianceStandards || []).map(comp => {
const isSelected = filters.complianceIds?.includes(comp.id);
return (
<button
key={comp.id}
onClick={() =>
onChangeFilters({
...filters,
complianceIds: toggleArrayItem(filters.complianceIds, comp.id),
})
}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all flex items-center gap-2 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 shadow-sm'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{comp.name}
<span className="text-[10px] opacity-70 font-mono">({comp._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
)}
{/* 5. Content Types */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Content Types
</h3>
<div className="grid grid-cols-1 gap-2">
{(meta?.contentTypes || []).map(ct => {
const isSelected = filters.contentTypes?.includes(ct.name);
const config = CONTENT_TYPE_CONFIG[ct.name] || {
label: ct.name,
icon: <FileText className="w-4 h-4" />,
color: 'bg-slate-100 text-slate-700',
};
return (
<button
key={ct.name}
onClick={() =>
onChangeFilters({
...filters,
contentTypes: toggleArrayItem(filters.contentTypes, ct.name),
})
}
className={`flex items-center justify-between p-2.5 rounded-lg border text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2.5">
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${
isSelected
? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
}`}
>
{ct.count}
</span>
</button>
);
})}
</div>
</div>
{/* 6. Subcategories */}
{filteredSubcategories.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Subcategories
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredSubcategories.map(sub => {
const isSelected = filters.subcategories?.includes(sub.name);
return (
<button
key={sub.name}
onClick={() =>
onChangeFilters({
...filters,
subcategories: toggleArrayItem(filters.subcategories, sub.name),
})
}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-semibold'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
{sub.name} <span className="opacity-60 text-[10px]">({sub.count})</span>
</button>
);
})}
</div>
</div>
)}
{/* 7. Tag Cloud */}
{filteredTags.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Tags & Keywords
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(tag => {
const isSelected = filters.tags?.includes(tag.name);
return (
<button
key={tag.name}
onClick={() =>
onChangeFilters({
...filters,
tags: toggleArrayItem(filters.tags, tag.name),
})
}
className={`px-2 py-0.5 rounded-full text-xs font-mono transition-all ${
isSelected
? 'bg-slate-900 text-white font-semibold dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
#{tag.name} <span className="opacity-60 text-[10px]">({tag.count})</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/80 dark:bg-slate-900/80 flex items-center justify-between gap-3">
<button
onClick={onClearAll}
disabled={activeCount === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Clear All ({activeCount})
</button>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md transition-all"
>
Apply Filters
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -32,6 +32,8 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
const [groups, setGroups] = useState<AssetGroup[]>([]); const [groups, setGroups] = useState<AssetGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>(''); const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [shareMode, setShareMode] = useState<'ALL' | 'SELECTED'>('ALL');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
getAssetGroups().then(setGroups).catch(console.error); getAssetGroups().then(setGroups).catch(console.error);
@ -41,16 +43,22 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
setSharesList( const existingShares = asset.sharedWith?.map(s => ({
asset.sharedWith?.map(s => ({
organizationId: s.organizationId, organizationId: s.organizationId,
userId: s.userId userId: s.userId
})) || [] })) || [];
setSharesList(existingShares);
// Pre-select mode: if shared with all orgs, set ALL, otherwise SELECTED
const isSharedWithAll = organizations.length > 0 && organizations.every(org =>
existingShares.some(s => s.organizationId === org.id && s.userId === null)
); );
setShareMode(isSharedWithAll ? 'ALL' : (existingShares.length === 0 ? 'ALL' : 'SELECTED'));
} else { } else {
setSharesList([]); setSharesList([]);
setShareMode('ALL');
} }
}, [asset, isOpen]); }, [asset, isOpen, organizations]);
const isOrgSharedEntirely = (orgId: string) => { const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null); return sharesList.some(s => s.organizationId === orgId && s.userId === null);
@ -90,12 +98,16 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
setIsSavingShare(true); setIsSavingShare(true);
try { try {
const targetShares = shareMode === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: sharesList;
if (asset) { if (asset) {
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: sharesList shares: targetShares
}); });
} else if (assetIds && assetIds.length > 0) { } else if (assetIds && assetIds.length > 0) {
await bulkShareAssets(assetIds, sharesList); await bulkShareAssets(assetIds, targetShares);
} }
if (selectedGroupId) { if (selectedGroupId) {
@ -105,7 +117,10 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
} }
} }
success('Share permissions updated', 'The asset visibility and group membership settings have been updated.'); success('Share permissions updated', shareMode === 'ALL'
? 'The asset has been shared with ALL partner organizations under the "All Assets" catalog tab.'
: 'The asset visibility and group access settings have been updated.'
);
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
@ -120,7 +135,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
<Modal <Modal
isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))} isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
onClose={onClose} onClose={onClose}
title="Share Settings" title="Share & Partner Access Settings"
subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`} subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
size="md" size="md"
footer={ footer={
@ -147,8 +162,50 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
> >
{(asset || (assetIds && assetIds.length > 0)) && ( {(asset || (assetIds && assetIds.length > 0)) && (
<form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4"> <form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
<p className="text-xs text-ink-600 leading-relaxed font-sans">
Select organizations or expand to specify exact users that can access this asset: {/* Share Scope Selector Pill */}
<div className="space-y-2">
<label className="text-[10px] font-extrabold uppercase tracking-wider text-ink-500 block font-sans">
Sharing Scope (Admin Access Control)
</label>
<div className="grid grid-cols-2 gap-2 p-1 bg-ink-100 rounded-xl border border-ink-200">
<button
type="button"
onClick={() => setShareMode('ALL')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'ALL'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
>
<span>🌐 Share with ALL Partners</span>
</button>
<button
type="button"
onClick={() => setShareMode('SELECTED')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'SELECTED'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
>
<span>👥 Selected Partners Only</span>
</button>
</div>
</div>
{shareMode === 'ALL' ? (
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl text-xs text-emerald-800 dark:text-emerald-300 font-sans space-y-1">
<span className="font-extrabold block">🌐 Global Access Mode Enabled</span>
<p className="leading-relaxed text-[11px]">
This asset will be automatically accessible to <strong>ALL registered partner organizations</strong> and visible under the "All Assets" catalog tab.
</p>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-ink-600 leading-relaxed font-sans font-medium">
Select specific partner organizations or expand to specify exact users:
</p> </p>
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin"> <div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
@ -229,6 +286,8 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
}) })
)} )}
</div> </div>
</div>
)}
{groups.length > 0 && ( {groups.length > 0 && (
<div className="pt-2 border-t border-ink-200"> <div className="pt-2 border-t border-ink-200">

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react'; import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy } from '../../../services/assets-api'; import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api';
import type { TaxonomyMeta, Organization } from '../../../types/assets';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast'; import { useToast } from '../../../hooks/use-toast';
@ -17,6 +18,16 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [shareScope, setShareScope] = useState<'ALL' | 'SELECTED'>('ALL');
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>([]);
const [selectedVerticalIds, setSelectedVerticalIds] = useState<string[]>([]);
const [selectedTechStackIds, setSelectedTechStackIds] = useState<string[]>([]);
const [selectedEngagementTypeIds, setSelectedEngagementTypeIds] = useState<string[]>([]);
const [selectedComplianceIds, setSelectedComplianceIds] = useState<string[]>([]);
const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file'); const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -34,8 +45,14 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [uploadTags, setUploadTags] = useState(''); const [uploadTags, setUploadTags] = useState('');
const [uploadGithubUrl, setUploadGithubUrl] = useState(''); const [uploadGithubUrl, setUploadGithubUrl] = useState('');
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true); const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [includeInKnowledgeBase, setIncludeInKnowledgeBase] = useState(true);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
getOrganizations().then(setOrganizations).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (!uploadFile) { if (!uploadFile) {
setPreviewUrl(null); setPreviewUrl(null);
@ -150,6 +167,18 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean))); formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
formData.append('githubUrl', uploadGithubUrl); formData.append('githubUrl', uploadGithubUrl);
formData.append('isDownloadable', String(uploadIsDownloadable)); formData.append('isDownloadable', String(uploadIsDownloadable));
if (selectedVerticalIds.length) {
formData.append('verticalIds', JSON.stringify(selectedVerticalIds));
}
if (selectedTechStackIds.length) {
formData.append('techStackIds', JSON.stringify(selectedTechStackIds));
}
if (selectedEngagementTypeIds.length) {
formData.append('engagementTypeIds', JSON.stringify(selectedEngagementTypeIds));
}
if (selectedComplianceIds.length) {
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
}
if (thumbnailUrl) { if (thumbnailUrl) {
formData.append('thumbnailUrl', thumbnailUrl); formData.append('thumbnailUrl', thumbnailUrl);
@ -158,6 +187,14 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('problemStatement', problemStatement); formData.append('problemStatement', problemStatement);
formData.append('solution', solution); formData.append('solution', solution);
} }
formData.append('includeInKnowledgeBase', String(includeInKnowledgeBase));
if (organizations.length > 0) {
const targetShares = shareScope === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: selectedOrgIds.map(orgId => ({ organizationId: orgId, userId: null }));
formData.append('shares', JSON.stringify(targetShares));
}
try { try {
await uploadAsset(formData); await uploadAsset(formData);
@ -173,6 +210,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
setUploadSubcategory(''); setUploadSubcategory('');
setUploadTags(''); setUploadTags('');
setUploadGithubUrl(''); setUploadGithubUrl('');
setSelectedVerticalIds([]);
setSelectedTechStackIds([]);
setSelectedEngagementTypeIds([]);
setSelectedComplianceIds([]);
setUploadIsDownloadable(true); setUploadIsDownloadable(true);
success('Asset published successfully', 'The asset has been added to the catalog.'); success('Asset published successfully', 'The asset has been added to the catalog.');
onSuccess(); onSuccess();
@ -185,6 +226,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
} }
}; };
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
return ( return (
<> <>
<Modal <Modal
@ -455,30 +500,130 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 4-Group Taxonomy Demarcation Selection */}
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<select 1. Industry Verticals / Domains
value={uploadCategory} </label>
onChange={(e) => setUploadCategory(e.target.value)} <div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900" {(taxonomyMeta?.verticals || []).map(v => {
const isSelected = selectedVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setSelectedVerticalIds(toggleSelection(selectedVerticalIds, v.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
> >
<option value="Marketing">Marketing</option> {v.name}
<option value="Presentations">Presentations</option> </button>
<option value="Branding">Branding</option> );
<option value="Resources">Resources</option> })}
<option value="Technical">Technical</option>
</select>
</div> </div>
</div>
{/* Group 2: Tech Stack */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<input 2. Technology Stack & Capabilities
type="text" </label>
value={uploadSubcategory} <div className="flex flex-col gap-2 max-h-40 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
onChange={(e) => setUploadSubcategory(e.target.value)} {['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
placeholder="e.g. Slide Deck" const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400" if (items.length === 0) return null;
/> return (
<div key={catName} className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{catName}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map(t => {
const isSelected = selectedTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setSelectedTechStackIds(toggleSelection(selectedTechStackIds, t.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Group 3 & Group 4 Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Group 3: Engagement Type */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
3. Engagement Type
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.engagementTypes || []).map(e => {
const isSelected = selectedEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setSelectedEngagementTypeIds(toggleSelection(selectedEngagementTypeIds, e.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{e.name}
</button>
);
})}
</div>
</div>
{/* Group 4: Compliance Standards */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
4. Compliance & Governance
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.complianceStandards || []).map(c => {
const isSelected = selectedComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setSelectedComplianceIds(toggleSelection(selectedComplianceIds, c.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>
@ -514,6 +659,87 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
</div> </div>
)} )}
<div className="flex items-start gap-3 p-3 bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl">
<input
type="checkbox"
id="includeInKnowledgeBase"
checked={includeInKnowledgeBase}
onChange={(e) => setIncludeInKnowledgeBase(e.target.checked)}
className="w-4 h-4 rounded border-emerald-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer mt-0.5"
/>
<div>
<label htmlFor="includeInKnowledgeBase" className="text-xs font-bold text-ink-900 dark:text-emerald-300 cursor-pointer flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-500" />
Include in AI Advisor Knowledge Base (RAG Search)
</label>
<span className="text-[10px] text-ink-500 dark:text-slate-400 block mt-0.5">
If checked, the AI Advisor indexes this content to answer partner questions with OKF standard citations.
</span>
</div>
</div>
{/* Partner Sharing & Visibility Scope */}
<div className="space-y-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Partner Visibility & Access Scope (Admin Control)
</h4>
<div className="grid grid-cols-2 gap-2 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setShareScope('ALL')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareScope === 'ALL'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Share with ALL Partners</span>
</button>
<button
type="button"
onClick={() => setShareScope('SELECTED')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareScope === 'SELECTED'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Selected Partners Only</span>
</button>
</div>
{shareScope === 'ALL' ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium p-2 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
🌐 This asset will be automatically shared with all registered partner organizations and accessible under the "All Assets" catalog tab.
</p>
) : (
<div className="space-y-1.5 max-h-36 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase block">Select Target Organizations:</span>
{organizations.length === 0 ? (
<p className="text-xs text-slate-400 italic">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isSelected = selectedOrgIds.includes(org.id);
return (
<label key={org.id} className="flex items-center gap-2 text-xs font-medium text-slate-800 dark:text-slate-200 cursor-pointer py-1 select-none">
<input
type="checkbox"
checked={isSelected}
onChange={() => setSelectedOrgIds(toggleSelection(selectedOrgIds, org.id))}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-700"
/>
<span>{org.name}</span>
</label>
);
})
)}
</div>
)}
</div>
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input <input

View File

@ -40,7 +40,7 @@
--color-info: #3b82f6; --color-info: #3b82f6;
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Poppins', 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* ── Spacing Scale ── */ /* ── Spacing Scale ── */

View File

@ -1,7 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { Variants } from "framer-motion"; import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, Share2, Folder, Sparkles } from "lucide-react"; import {
UploadCloud, Search, File, Share2, Folder, Sparkles, Filter, LayoutGrid,
List, ArrowUpDown, Shield, X
} from "lucide-react";
import { useAuthStore } from "../hooks/use-auth"; import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios"; import { axiosInstance } from "../services/axios";
import { import {
@ -13,6 +17,7 @@ import {
rejectDownloadRequest, rejectDownloadRequest,
downloadAssetFile, downloadAssetFile,
getAssetGroups, getAssetGroups,
getTaxonomyMeta,
} from "../services/assets-api"; } from "../services/assets-api";
import type { AssetGroup } from "../services/assets-api"; import type { AssetGroup } from "../services/assets-api";
import { useToast } from "../hooks/use-toast"; import { useToast } from "../hooks/use-toast";
@ -26,21 +31,25 @@ import { AssetDetailsModal } from "../features/assets/components/AssetDetailsMod
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal"; import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal"; import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal"; import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
import { FilterDrawer } from "../features/assets/components/FilterDrawer";
import { AssetTableView } from "../features/assets/components/AssetTableView";
import { AssetAdminManagerModal } from "../features/assets/components/AssetAdminManagerModal";
import { PageHeader } from "../components/ui/PageHeader"; import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button"; import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout"; import { PageLayout } from "../components/layout/PageLayout";
import Modal from "../components/ui/Modal"; import Modal from "../components/ui/Modal";
// Type Definitions // Type Definitions
import type { Asset, Organization } from "../types/assets"; import type { Asset, Organization, TaxonomyMeta, AssetQueryFilters } from "../types/assets";
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } }, show: { opacity: 1, transition: { staggerChildren: 0.04 } },
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { opacity: 0, y: 15, scale: 0.98 }, hidden: { opacity: 0, y: 12, scale: 0.98 },
show: { show: {
opacity: 1, opacity: 1,
y: 0, y: 0,
@ -50,29 +59,40 @@ const itemVariants: Variants = {
}; };
export const AssetsPage = () => { export const AssetsPage = () => {
const location = useLocation();
const { success, error } = useToast(); const { success, error } = useToast();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<AssetGroup[]>([]); const [groups, setGroups] = useState<AssetGroup[]>([]);
// Search & Filter // Layout Density State
const [viewMode, setViewMode] = useState<'grid' | 'compact' | 'table'>('grid');
// Search, Sort & Filter State
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL"); const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type'>('newest');
const [filters, setFilters] = useState<AssetQueryFilters>({});
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups) // Slide-over Filter Drawer & Admin Manager Controls
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [isAdminManagerOpen, setIsAdminManagerOpen] = useState(false);
// Multi-Selection State (Shift+Click Engine)
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// Recommended assets logic
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER" const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase()) ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: []; : [];
// Only recommend assets that the partner actually has permission to access, and deduplicate
const recommendedAssets = (() => { const recommendedAssets = (() => {
if (partnerGroupStrings.length === 0) return []; if (partnerGroupStrings.length === 0) return [];
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase())); const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
return activeGroups return activeGroups
.flatMap(g => g.assets) .flatMap(g => g.assets)
.filter((recAsset, index, self) => .filter((recAsset, index, self) =>
@ -81,6 +101,23 @@ export const AssetsPage = () => {
); );
})(); })();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl", "transition-all", "duration-500");
setTimeout(() => {
el.classList.remove("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl");
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Modal Control States // Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
@ -92,7 +129,6 @@ export const AssetsPage = () => {
const [activeAsset, setActiveAsset] = useState<Asset | null>(null); const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null); const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null); const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null); const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null);
@ -100,21 +136,17 @@ export const AssetsPage = () => {
fetchData(); fetchData();
}, []); }, []);
useEffect(() => {
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
setSelectedCategory("ALL");
}
}, [assets, selectedCategory]);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const assetsData = await getAssets(); const [assetsData, metaData, groupsData] = await Promise.all([
setAssets(assetsData); getAssets({ ...filters, search: searchQuery, sortBy }),
setSelectedAssetIds([]); getTaxonomyMeta(),
getAssetGroups(),
]);
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations) setAssets(assetsData);
const groupsData = await getAssetGroups(); setTaxonomyMeta(metaData);
setGroups(groupsData); setGroups(groupsData);
if (user?.role === "ADMIN") { if (user?.role === "ADMIN") {
@ -128,6 +160,53 @@ export const AssetsPage = () => {
} }
}; };
// Re-fetch when query or filters change
useEffect(() => {
const timer = setTimeout(() => {
fetchFilteredAssets();
}, 250);
return () => clearTimeout(timer);
}, [searchQuery, sortBy, filters]);
const fetchFilteredAssets = async () => {
try {
const assetsData = await getAssets({
...filters,
search: searchQuery,
sortBy,
});
setAssets(assetsData);
} catch (err) {
console.error("Failed to filter assets", err);
}
};
// Shift + Click Range Multi-Selection Handler
const handleToggleSelectAsset = (assetId: string, event?: React.MouseEvent) => {
const clickedIndex = filteredAssets.findIndex(a => a.id === assetId);
if (event?.shiftKey && lastSelectedIndex !== null && clickedIndex !== -1) {
const start = Math.min(lastSelectedIndex, clickedIndex);
const end = Math.max(lastSelectedIndex, clickedIndex);
const rangeIds = filteredAssets.slice(start, end + 1).map(a => a.id);
setSelectedAssetIds(prev => Array.from(new Set([...prev, ...rangeIds])));
} else {
setSelectedAssetIds(prev =>
prev.includes(assetId) ? prev.filter(id => id !== assetId) : [...prev, assetId]
);
setLastSelectedIndex(clickedIndex);
}
};
const handleSelectAll = () => {
if (selectedAssetIds.length === filteredAssets.length) {
setSelectedAssetIds([]);
} else {
setSelectedAssetIds(filteredAssets.map(a => a.id));
}
};
const openEditModal = (asset: Asset) => { const openEditModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsEditOpen(true); setIsEditOpen(true);
@ -139,14 +218,6 @@ export const AssetsPage = () => {
setIsShareOpen(true); setIsShareOpen(true);
}; };
const handleToggleSelectAsset = (assetId: string) => {
setSelectedAssetIds((prev) =>
prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId]
);
};
const openViewerModal = (asset: Asset) => { const openViewerModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsViewerOpen(true); setIsViewerOpen(true);
@ -172,7 +243,6 @@ export const AssetsPage = () => {
setAssetToDelete(null); setAssetToDelete(null);
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to delete asset", err);
error("Failed to delete asset", err.response?.data?.error || "Something went wrong."); error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -183,7 +253,6 @@ export const AssetsPage = () => {
success("Download request submitted", "An administrator has been notified of your request."); success("Download request submitted", "An administrator has been notified of your request.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to request download access", err);
error("Failed to submit request", err.response?.data?.error || "Something went wrong."); error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -194,7 +263,6 @@ export const AssetsPage = () => {
success("Download request approved", "The partner can now download this asset."); success("Download request approved", "The partner can now download this asset.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to approve request", err);
error("Failed to approve request", err.response?.data?.error || "Something went wrong."); error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -205,7 +273,6 @@ export const AssetsPage = () => {
success("Download request rejected", "The access request was denied."); success("Download request rejected", "The access request was denied.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err: any) {
console.error("Failed to reject request", err);
error("Failed to reject request", err.response?.data?.error || "Something went wrong."); error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
} }
}; };
@ -214,7 +281,6 @@ export const AssetsPage = () => {
success("Download started", `Downloading "${asset.title}"...`); success("Download started", `Downloading "${asset.title}"...`);
try { try {
await downloadAssetFile(asset.id); await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith("http") const downloadUrl = asset.url.startsWith("http")
? asset.url ? asset.url
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`; : `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
@ -227,119 +293,183 @@ export const AssetsPage = () => {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
setAssets((prev) => setAssets(prev =>
prev.map((item) => prev.map(item => item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item)
item.id === asset.id
? { ...item, downloadsCount: item.downloadsCount + 1 }
: item,
),
); );
} catch (err: any) { } catch (err: any) {
console.error("Failed to process download", err);
error("Download failed", err.response?.data?.error || "Could not retrieve asset file."); error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
} }
}; };
// Client-side filtering fallback
const filteredAssets = assets.filter((asset) => { const filteredAssets = assets.filter((asset) => {
const query = searchQuery.toLowerCase().trim(); if (selectedCategory === "RECOMMENDED") {
return recommendedAssets.some((r) => r.id === asset.id);
const matchesSearch = }
!query || return true;
asset.title.toLowerCase().includes(query) ||
(asset.description && asset.description.toLowerCase().includes(query)) ||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
asset.type.toLowerCase().includes(query) ||
asset.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesCategory =
selectedCategory === "ALL" ||
(selectedCategory === "RECOMMENDED" &&
recommendedAssets.some((r) => r.id === asset.id));
return matchesSearch && matchesCategory;
}); });
const activeFilterCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const pendingRequestsCount = assets.reduce((acc, asset) => { const pendingRequestsCount = assets.reduce((acc, asset) => {
return ( return (
acc + acc + (asset.downloadRequests?.filter((r) => r.status === "PENDING").length || 0)
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
0)
); );
}, 0); }, 0);
// Header component // Header component
const headerNode = ( const headerNode = (
<PageHeader <PageHeader
title="Asset Library" title="Asset Discovery Platform"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources." subtitle="Enterprise asset catalog featuring vertical domain search, multi-view density, and partner access controls."
// badge={
// <div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <CheckCircle className="w-3.5 h-3.5 text-ink-900" />
// <span>Global CDN Active</span>
// </div>
// }
/> />
); );
// Toolbar component // Toolbar component
const toolbarNode = ( const toolbarNode = (
<div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm"> <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between p-3.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3 flex-1 min-w-0 w-full"> <div className="flex flex-wrap items-center gap-3 flex-1 min-w-0">
{/* Search Bar with stable minimum width */}
<div className="relative w-full md:w-[280px] shrink-0 group"> {/* Search Bar */}
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="relative w-full sm:w-[280px] shrink-0">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
</div>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-ink-50 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 hover:border-ink-300 font-sans" className="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-2 pl-9 pr-4 text-xs font-medium text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none focus:ring-2 ring-slate-400/20 focus:border-slate-500 transition-all"
placeholder="Search by title, desc, tag, category..." placeholder="Search catalog titles, tags..."
/> />
</div> </div>
{/* Toggle Controls: All Assets vs Recommended */} {/* Sorting Dropdown */}
<div className="flex items-center gap-1.5 bg-slate-50 dark:bg-slate-800 px-2.5 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<ArrowUpDown className="w-3.5 h-3.5 text-slate-400" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="bg-transparent text-xs font-semibold text-slate-700 dark:text-slate-300 outline-none cursor-pointer"
>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
<option value="title_asc">Title A-Z</option>
<option value="title_desc">Title Z-A</option>
<option value="type">Format / Type</option>
</select>
</div>
{/* View Density Switcher */}
<div className="flex items-center p-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Grid Cards"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('compact')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'compact'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Compact Cards"
>
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'table'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Tabular List View (Shift+Click)"
>
<List className="w-4 h-4" />
</button>
</div>
{/* Partner Recommended Toggle */}
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && ( {user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
<div className="flex items-center gap-1 bg-ink-50/50 p-1 rounded-xl border border-ink-200 shadow-sm shrink-0"> <div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl shrink-0">
<button <button
onClick={() => setSelectedCategory("ALL")} onClick={() => setSelectedCategory("ALL")}
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap ${selectedCategory === "ALL" className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL"
? "bg-ink-900 text-ink-0 shadow-sm" ? "bg-slate-900 text-white shadow-xs"
: "text-ink-600 hover:text-ink-900 hover:bg-ink-100/50" : "text-slate-600 dark:text-slate-300 hover:bg-slate-200"
}`} }`}
> >
All Assets All Assets
</button> </button>
<button <button
onClick={() => setSelectedCategory("RECOMMENDED")} onClick={() => setSelectedCategory("RECOMMENDED")}
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap flex items-center gap-1.5 ${selectedCategory === "RECOMMENDED" className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${selectedCategory === "RECOMMENDED"
? "bg-gradient-to-r from-amber-500 to-amber-600 text-zinc-950 shadow-md shadow-amber-500/25" ? "bg-amber-500 text-slate-950 font-bold shadow-xs"
: "text-amber-700 dark:text-amber-400 hover:bg-amber-500/10" : "text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
}`} }`}
> >
<Sparkles className="w-3.5 h-3.5 animate-pulse" /> <Sparkles className="w-3.5 h-3.5" />
<span>Recommended for You</span> <span>Recommended ({recommendedAssets.length})</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end"> {/* Right Action & Filter Drawer Trigger Area */}
<div className="flex items-center gap-2 shrink-0 justify-end">
{user?.role === "ADMIN" && (
<>
{selectedAssetIds.length > 0 ? (
<Button
onClick={() => setSelectedAssetIds([])}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Deselect All
</Button>
) : (
<Button
onClick={handleSelectAll}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Select All
</Button>
)}
<Button
onClick={() => setIsAdminManagerOpen(true)}
variant="secondary"
size="sm"
icon={<Shield className="w-3.5 h-3.5 text-slate-700 dark:text-slate-300" />}
>
Taxonomy & Announcements
</Button>
</>
)}
{user?.role === "ADMIN" && pendingRequestsCount > 0 && ( {user?.role === "ADMIN" && pendingRequestsCount > 0 && (
<Button <Button
onClick={() => setIsRequestsOpen(true)} onClick={() => setIsRequestsOpen(true)}
variant="secondary" variant="secondary"
size="sm" size="sm"
icon={ icon={
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold mr-0.5"> <span className="w-4 h-4 rounded-full bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-[10px] flex items-center justify-center font-bold">
{pendingRequestsCount} {pendingRequestsCount}
</span> </span>
} }
> >
Download Requests Requests
</Button> </Button>
)} )}
@ -353,7 +483,7 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<Share2 className="w-3.5 h-3.5" />} icon={<Share2 className="w-3.5 h-3.5" />}
> >
Share Selected ({selectedAssetIds.length}) Share ({selectedAssetIds.length})
</Button> </Button>
)} )}
@ -364,7 +494,7 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<Folder className="w-3.5 h-3.5" />} icon={<Folder className="w-3.5 h-3.5" />}
> >
Manage Groups Groups
</Button> </Button>
)} )}
@ -375,65 +505,108 @@ export const AssetsPage = () => {
size="sm" size="sm"
icon={<UploadCloud className="w-3.5 h-3.5" />} icon={<UploadCloud className="w-3.5 h-3.5" />}
> >
Create / Upload Asset Create Asset
</Button> </Button>
)} )}
{/* Filter Drawer Trigger Button positioned on the far right end */}
<button
onClick={() => setIsFilterDrawerOpen(true)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${activeFilterCount > 0
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900"
: "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="px-1.5 py-0.2 text-[10px] font-extrabold rounded-full bg-amber-500 text-slate-950">
{activeFilterCount}
</span>
)}
</button>
</div> </div>
</div> </div>
); );
return ( return (
<PageLayout header={headerNode} toolbar={toolbarNode}> <PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto"> <div className="p-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
{/* Result Counter & Active Filter Badges */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-xs">
<div className="text-slate-500 font-medium flex items-center gap-2">
<span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span>
{selectedAssetIds.length > 0 && (
<span className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-bold border border-slate-300 dark:border-slate-700">
{selectedAssetIds.length} selected
</span>
)}
</div>
{activeFilterCount > 0 && (
<button
onClick={() => setFilters({})}
className="text-xs text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-bold flex items-center gap-1"
>
<X className="w-3.5 h-3.5" />
Reset Filters ({activeFilterCount})
</button>
)}
</div>
{loading ? ( {loading ? (
<div className="py-20 flex justify-center items-center"> <div className="py-20 flex justify-center items-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-slate-400/30 border-t-slate-900 dark:border-t-slate-100 rounded-full animate-spin" />
</div> </div>
) : ( ) : (
<> <>
{selectedCategory === "RECOMMENDED" && (
<div className="mb-8 p-8 bg-gradient-to-br from-amber-500/[0.07] via-slate-900/40 to-slate-950/20 border border-amber-500/20 rounded-2xl relative overflow-hidden shadow-lg backdrop-blur-md">
{/* Background decorative elements */}
<div className="absolute top-0 right-0 w-80 h-80 bg-amber-500/[0.04] rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
<div className="absolute -left-10 -bottom-10 w-60 h-60 bg-amber-600/[0.02] rounded-full blur-3xl pointer-events-none" />
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-700 dark:text-amber-400 uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>Curated Catalog</span>
</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md bg-ink-900/60 text-ink-200 text-[10px] font-bold uppercase tracking-wider border border-ink-700/30">
{recommendedAssets.length} {recommendedAssets.length === 1 ? 'Asset' : 'Assets'}
</span>
</div>
<h2 className="text-3xl font-extrabold text-ink-900 font-sans tracking-tight">Recommended for You</h2>
<p className="text-ink-600 text-sm max-w-2xl leading-relaxed">
These resources have been hand-picked by our team to match your partner profile and accelerate your integration.
</p>
</div>
</div>
</div>
)}
{filteredAssets.length === 0 ? ( {filteredAssets.length === 0 ? (
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl"> <div className="py-16 text-center bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl">
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" /> <File className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">No assets found</h3> <h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">No assets match your query</h3>
<p className="text-ink-500 text-sm mt-1"> <p className="text-slate-500 text-xs mt-1 max-w-sm mx-auto">
There are no assets matching your criteria. Try broadening your search keywords or clearing active domain filters.
</p> </p>
<button
onClick={() => { setSearchQuery(''); setFilters({}); }}
className="mt-4 px-4 py-2 rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-xs font-bold"
>
Clear All Filters
</button>
</div> </div>
) : viewMode === 'table' ? (
/* TABULAR LIST VIEW */
<AssetTableView
assets={filteredAssets}
selectedIds={selectedAssetIds}
recommendedIds={recommendedAssets.map(r => r.id)}
onToggleSelect={handleToggleSelectAsset}
onSelectAll={handleSelectAll}
onOpenAsset={openViewerModal}
onRequestDownload={(id) => {
const asset = assets.find(a => a.id === id);
if (asset) handleRequestDownload(asset);
}}
userRole={user?.role}
/>
) : ( ) : (
/* GRID / COMPACT VIEW */
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="show" animate="show"
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start" className={`grid grid-cols-1 ${viewMode === 'compact'
? 'sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3'
: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6'
} items-start`}
> >
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col"> <motion.div
key={asset.id}
variants={itemVariants}
className={`relative ${viewMode === 'compact' ? 'h-[360px]' : 'h-[410px]'} w-full flex flex-col`}
>
<AssetCard <AssetCard
asset={asset} asset={asset}
user={user} user={user}
@ -447,7 +620,7 @@ export const AssetsPage = () => {
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)} isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={handleToggleSelectAsset} onToggleSelect={(id, event) => handleToggleSelectAsset(id, event)}
isExpanded={expandedAssetId === asset.id} isExpanded={expandedAssetId === asset.id}
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)} onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
isRecommended={recommendedAssets.some(r => r.id === asset.id)} isRecommended={recommendedAssets.some(r => r.id === asset.id)}
@ -460,6 +633,26 @@ export const AssetsPage = () => {
)} )}
</div> </div>
{/* Slide-over Filter Drawer */}
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
meta={taxonomyMeta}
filters={filters}
onChangeFilters={setFilters}
onClearAll={() => setFilters({})}
/>
{/* Admin Taxonomy & Announcements Control Modal */}
<AssetAdminManagerModal
isOpen={isAdminManagerOpen}
onClose={() => setIsAdminManagerOpen(false)}
meta={taxonomyMeta}
organizations={organizations}
allAssets={assets}
onRefreshMeta={fetchData}
/>
{/* Modals Container */} {/* Modals Container */}
<UploadAssetModal <UploadAssetModal
isOpen={isUploadOpen} isOpen={isUploadOpen}
@ -536,8 +729,8 @@ export const AssetsPage = () => {
size="sm" size="sm"
> >
<div className="space-y-4 font-sans"> <div className="space-y-4 font-sans">
<p className="text-xs text-ink-600 leading-relaxed"> <p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Are you sure you want to permanently delete <span className="font-bold text-ink-900">"{assetToDelete?.title}"</span>? This action cannot be undone. Are you sure you want to permanently delete <span className="font-bold text-slate-900 dark:text-white">"{assetToDelete?.title}"</span>? This action cannot be undone.
</p> </p>
<div className="flex gap-3 justify-end pt-2"> <div className="flex gap-3 justify-end pt-2">
<Button <Button

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw } from 'lucide-react'; import { useLocation } from 'react-router-dom';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { useMyAcceptancesQuery } from '../hooks/use-legal-query'; import { useMyAcceptancesQuery } from '../hooks/use-legal-query';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
@ -8,10 +9,28 @@ import { PageLayout } from '../components/layout/PageLayout';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
export const ClientAgreementsPage: React.FC = () => { export const ClientAgreementsPage: React.FC = () => {
const location = useLocation();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery(); const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery();
const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null); const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !isLoading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`) || document.getElementById('asset-card-nda');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, isLoading]);
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA'); const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA'); const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
@ -61,12 +80,49 @@ export const ClientAgreementsPage: React.FC = () => {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NDA Card */} {/* NDA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300"> <div
id={`asset-card-${ndaAcceptance?.document?.id || 'nda'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Mutual Non-Disclosure Agreement (NDA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect NDA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{ndaAcceptance ? ( {ndaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250"> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
@ -136,12 +192,49 @@ export const ClientAgreementsPage: React.FC = () => {
</div> </div>
{/* MSA Card */} {/* MSA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300"> <div
id={`asset-card-${msaAcceptance?.document?.id || 'msa'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Master Services Agreement (MSA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect MSA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{msaAcceptance ? ( {msaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250"> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
@ -284,7 +377,7 @@ export const ClientAgreementsPage: React.FC = () => {
`Verification Hash: ${activeDocData.signatureHash || 'N/A'}\n`, `Verification Hash: ${activeDocData.signatureHash || 'N/A'}\n`,
`IP Address: ${activeDocData.ipAddress}\n`, `IP Address: ${activeDocData.ipAddress}\n`,
`Signed On: ${new Date(activeDocData.acceptedAt).toLocaleString()}\n` `Signed On: ${new Date(activeDocData.acceptedAt).toLocaleString()}\n`
], {type: 'text/plain'}); ], { type: 'text/plain' });
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = `${selectedDoc}_Agreement_${user?.email?.split('@')[0]}.txt`; element.download = `${selectedDoc}_Agreement_${user?.email?.split('@')[0]}.txt`;
document.body.appendChild(element); document.body.appendChild(element);

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Code, Code,
@ -7,7 +8,8 @@ import {
Cloud, Cloud,
Globe, Globe,
ExternalLink, ExternalLink,
Layers Layers,
Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout'; import { PageLayout } from '../components/layout/PageLayout';
@ -24,10 +26,28 @@ const iconMap: Record<string, any> = {
}; };
export const EcosystemPage: React.FC = () => { export const EcosystemPage: React.FC = () => {
const location = useLocation();
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]); const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL'); const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
@ -70,8 +90,7 @@ export const EcosystemPage: React.FC = () => {
<button <button
key={type} key={type}
onClick={() => setFilter(type)} onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${ className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${filter === type
filter === type
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900' : 'text-ink-500 hover:text-ink-900'
}`} }`}
@ -98,7 +117,7 @@ export const EcosystemPage: React.FC = () => {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }} exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
className="grid grid-cols-1 md:grid-cols-2 gap-8" className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8"
> >
{filteredOfferings.map((offering) => { {filteredOfferings.map((offering) => {
const IconComponent = iconMap[offering.logoIcon] || Globe; const IconComponent = iconMap[offering.logoIcon] || Globe;
@ -107,12 +126,27 @@ export const EcosystemPage: React.FC = () => {
return ( return (
<div <div
key={offering.id} key={offering.id}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500" id={`asset-card-${offering.id}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', offering.name);
}}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500 cursor-pointer"
> >
<div> <div>
{/* Logo and Badges */} {/* Logo and Badges */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div className="h-10 flex items-center shrink-0"> <div className="h-10 flex items-center shrink-0 gap-3">
{offering.logoUrl ? ( {offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" /> <BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" />
) : ( ) : (
@ -122,8 +156,29 @@ export const EcosystemPage: React.FC = () => {
)} )}
</div> </div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${ <div className="flex items-center gap-2">
isProduct <button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Offering with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${isProduct
? 'bg-blue-500/10 text-blue-600 border-blue-500/20' ? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' : 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}> }`}>
@ -131,6 +186,7 @@ export const EcosystemPage: React.FC = () => {
{offering.type} {offering.type}
</span> </span>
</div> </div>
</div>
{/* Info Copy */} {/* Info Copy */}
<div className="space-y-3"> <div className="space-y-3">
@ -152,8 +208,7 @@ export const EcosystemPage: React.FC = () => {
<ul className="grid grid-cols-1 gap-2.5 pt-1"> <ul className="grid grid-cols-1 gap-2.5 pt-1">
{offering.benefits.map((benefit, bIdx) => ( {offering.benefits.map((benefit, bIdx) => (
<li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600"> <li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${ <span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${isProduct ? 'bg-blue-500' : 'bg-emerald-500'
isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} /> }`} />
<span>{benefit}</span> <span>{benefit}</span>
</li> </li>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Play, Play,
@ -7,7 +8,8 @@ import {
Video, Video,
Maximize2, Maximize2,
Tv, Tv,
Monitor Monitor,
Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout'; import { PageLayout } from '../components/layout/PageLayout';
@ -127,8 +129,7 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<p <p
className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${ className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${isExpanded ? '' : 'line-clamp-2'
isExpanded ? '' : 'line-clamp-2'
}`} }`}
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }} style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
> >
@ -150,11 +151,29 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
}; };
export const ShowcasePage: React.FC = () => { export const ShowcasePage: React.FC = () => {
const location = useLocation();
const [items, setItems] = useState<ContentShowcase[]>([]); const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null); const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null); const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Resizable Lightbox state: compact | theater | cinema // Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact'); const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
@ -210,7 +229,7 @@ export const ShowcasePage: React.FC = () => {
<p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p> <p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 animate-fadeIn"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 animate-fadeIn">
{items.map((item) => { {items.map((item) => {
const ytId = extractYouTubeVideoId(item.youtubeUrl); const ytId = extractYouTubeVideoId(item.youtubeUrl);
const isIg = item.youtubeUrl.includes('instagram.com'); const isIg = item.youtubeUrl.includes('instagram.com');
@ -219,18 +238,51 @@ export const ShowcasePage: React.FC = () => {
const isExpanded = expandedItemId === item.id; const isExpanded = expandedItemId === item.id;
return ( return (
<div key={item.id} className="relative h-[410px] w-full flex flex-col"> <div key={item.id} id={`asset-card-${item.id}`} className="relative h-[410px] w-full flex flex-col">
<motion.div <motion.div
layout layout
draggable={true}
onDragStart={(e: any) => {
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', item.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }} transition={{ type: "spring", stiffness: 320, damping: 28 }}
className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${ className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${isExpanded
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0' ? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0'
: 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl' : 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl'
}`} }`}
> >
{/* Video Player / Thumbnail */} {/* Video Player / Thumbnail */}
<div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0"> <div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="absolute top-2 right-2 z-20 px-2 py-1 rounded-md bg-ink-900/90 text-ink-0 hover:bg-ink-950 text-[10px] font-bold shadow-md transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Reel with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{thumbnail ? ( {thumbnail ? (
<img <img
src={thumbnail} src={thumbnail}
@ -239,8 +291,7 @@ export const ShowcasePage: React.FC = () => {
/> />
) : ( ) : (
/* Platform Fallback Gradients */ /* Platform Fallback Gradients */
<div className={`w-full h-full flex items-center justify-center ${ <div className={`w-full h-full flex items-center justify-center ${isIg
isIg
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600' ? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
: isTw : isTw
? 'bg-ink-950' ? 'bg-ink-950'
@ -336,8 +387,7 @@ export const ShowcasePage: React.FC = () => {
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 250 }} transition={{ type: 'spring', damping: 25, stiffness: 250 }}
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${ className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${lightboxSize === 'cinema'
lightboxSize === 'cinema'
? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]' ? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]'
: lightboxSize === 'theater' : lightboxSize === 'theater'
? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]' ? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]'
@ -368,8 +418,7 @@ export const ShowcasePage: React.FC = () => {
</div> </div>
{/* Info Container */} {/* Info Container */}
<div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${ <div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${lightboxSize === 'cinema'
lightboxSize === 'cinema'
? 'w-full border-t h-[40%] md:h-[30%] shrink-0' ? 'w-full border-t h-[40%] md:h-[30%] shrink-0'
: 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0' : 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0'
}`}> }`}>
@ -379,8 +428,7 @@ export const ShowcasePage: React.FC = () => {
<div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5"> <div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
<button <button
onClick={() => setLightboxSize('compact')} onClick={() => setLightboxSize('compact')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${ className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'compact'
lightboxSize === 'compact'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}
@ -391,8 +439,7 @@ export const ShowcasePage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setLightboxSize('theater')} onClick={() => setLightboxSize('theater')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${ className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'theater'
lightboxSize === 'theater'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}
@ -403,8 +450,7 @@ export const ShowcasePage: React.FC = () => {
</button> </button>
<button <button
onClick={() => setLightboxSize('cinema')} onClick={() => setLightboxSize('cinema')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${ className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'cinema'
lightboxSize === 'cinema'
? 'bg-ink-800 text-ink-0' ? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300' : 'text-ink-500 hover:text-ink-300'
}`} }`}

View File

@ -1,8 +1,19 @@
import { axiosInstance } from './axios'; import { axiosInstance } from './axios';
import type { Asset, Organization } from '../types/assets'; import type { Asset, Organization, Vertical, TechStack, EngagementType, ComplianceStandard, TaxonomyMeta, AssetQueryFilters } from '../types/assets';
export const getAssets = async (): Promise<Asset[]> => { export const getAssets = async (filters?: AssetQueryFilters): Promise<Asset[]> => {
const response = await axiosInstance.get<Asset[]>('/assets'); const params: Record<string, string> = {};
if (filters?.search) params.search = filters.search;
if (filters?.verticalIds?.length) params.verticalIds = filters.verticalIds.join(',');
if (filters?.techStackIds?.length) params.techStackIds = filters.techStackIds.join(',');
if (filters?.engagementTypeIds?.length) params.engagementTypeIds = filters.engagementTypeIds.join(',');
if (filters?.complianceIds?.length) params.complianceIds = filters.complianceIds.join(',');
if (filters?.contentTypes?.length) params.contentTypes = filters.contentTypes.join(',');
if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(',');
if (filters?.tags?.length) params.tags = filters.tags.join(',');
if (filters?.sortBy) params.sortBy = filters.sortBy;
const response = await axiosInstance.get<Asset[]>('/assets', { params });
return response.data; return response.data;
}; };
@ -24,6 +35,10 @@ export const updateAsset = async (
description?: string; description?: string;
categoryId?: string; categoryId?: string;
subcategory?: string; subcategory?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
tags?: string[]; tags?: string[];
githubUrl?: string; githubUrl?: string;
isDownloadable?: boolean; isDownloadable?: boolean;
@ -104,3 +119,61 @@ export const scrapeCaseStudy = async (url: string): Promise<ScrapedCaseStudy> =>
}); });
return response.data; return response.data;
}; };
// Taxonomy API
export const getVerticals = async (): Promise<Vertical[]> => {
const response = await axiosInstance.get<Vertical[]>('/taxonomy/verticals');
return response.data;
};
export const getTaxonomyMeta = async (): Promise<TaxonomyMeta> => {
const response = await axiosInstance.get<TaxonomyMeta>('/taxonomy/meta');
return response.data;
};
export const createVertical = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<Vertical> => {
const response = await axiosInstance.post<Vertical>('/taxonomy/verticals', payload);
return response.data;
};
export const updateVertical = async (id: string, payload: Partial<Vertical>): Promise<Vertical> => {
const response = await axiosInstance.put<Vertical>(`/taxonomy/verticals/${id}`, payload);
return response.data;
};
export const deleteVertical = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/verticals/${id}`);
};
export const createTechStack = async (payload: { name: string; category?: string; icon?: string; description?: string; color?: string }): Promise<TechStack> => {
const response = await axiosInstance.post<TechStack>('/taxonomy/tech-stacks', payload);
return response.data;
};
export const deleteTechStack = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/tech-stacks/${id}`);
};
export const createEngagementType = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<EngagementType> => {
const response = await axiosInstance.post<EngagementType>('/taxonomy/engagement-types', payload);
return response.data;
};
export const deleteEngagementType = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/engagement-types/${id}`);
};
export const createComplianceStandard = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<ComplianceStandard> => {
const response = await axiosInstance.post<ComplianceStandard>('/taxonomy/compliance-standards', payload);
return response.data;
};
export const deleteComplianceStandard = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/compliance-standards/${id}`);
};
// Notification API
export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => {
await axiosInstance.post('/assets/notify', payload);
};

View File

@ -26,6 +26,87 @@ export interface DownloadRequest {
}; };
} }
export interface Vertical {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface TechStack {
id: string;
name: string;
slug: string;
category: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface EngagementType {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface ComplianceStandard {
id: string;
name: string;
slug: string;
icon?: string | null;
description?: string | null;
color?: string | null;
orderIndex?: number;
isActive?: boolean;
_count?: {
assets: number;
};
}
export interface TaxonomyMeta {
verticals: Vertical[];
techStacks: TechStack[];
engagementTypes: EngagementType[];
complianceStandards: ComplianceStandard[];
categories: { name: string; count: number }[];
subcategories: { name: string; count: number }[];
contentTypes: { name: string; count: number }[];
tags: { name: string; count: number }[];
totalAssets: number;
}
export interface AssetQueryFilters {
search?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
export interface Asset { export interface Asset {
id: string; id: string;
title: string; title: string;
@ -37,6 +118,7 @@ export interface Asset {
description: string | null; description: string | null;
categoryId: string | null; categoryId: string | null;
subcategory: string | null; subcategory: string | null;
contentType?: string | null;
tags: string[]; tags: string[];
downloadsCount: number; downloadsCount: number;
githubUrl: string | null; githubUrl: string | null;
@ -46,6 +128,10 @@ export interface Asset {
problemStatement?: string | null; problemStatement?: string | null;
solution?: string | null; solution?: string | null;
createdAt: string; createdAt: string;
verticals?: Vertical[];
techStacks?: TechStack[];
engagementTypes?: EngagementType[];
complianceStandards?: ComplianceStandard[];
sharedWith?: SharedWithOrg[]; sharedWith?: SharedWithOrg[];
downloadRequests?: DownloadRequest[]; downloadRequests?: DownloadRequest[];
} }

View File

@ -7,8 +7,7 @@ export default defineConfig({
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
server: { server: {
allowedHosts: [ allowedHosts: [
"toughly-coinstantaneous-dimple.ngrok-free.dev", "hurricane-reverence-robin.ngrok-free.dev"
"spruce-fridge-destiny.ngrok-free.dev"
], ],
cors: true, cors: true,
proxy: { proxy: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

File diff suppressed because it is too large Load Diff

View File

@ -34,4 +34,4 @@ fi
# 3. Start Vite Dev Server # 3. Start Vite Dev Server
info "Starting Vite frontend dev server..." info "Starting Vite frontend dev server..."
npm run dev -- --host node --max-old-space-size=512 node_modules/vite/bin/vite.js --host

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff