Valid_filters_and_texanomy

This commit is contained in:
kenilkb 2026-07-22 12:33:24 +05:30
parent 61f537057b
commit a127b5ffc4
24 changed files with 1486 additions and 3860 deletions

4
.gitignore vendored
View File

@ -35,4 +35,6 @@ uploads/*
/memory.md /memory.md
/phases.md /phases.md
/architecture.md /architecture.md
/Guide.md /Guide.md
# Dedicated Documentation Folder
/documents/

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

@ -78,13 +78,16 @@ model Asset {
solution String? @db.Text solution String? @db.Text
contentType String? contentType String?
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") 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[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Vertical { model Vertical {
@ -101,6 +104,49 @@ model Vertical {
updatedAt DateTime @updatedAt 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 { model AssetNotification {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String

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

@ -59,10 +59,18 @@ export class AssetController {
fileUrl = req.body.url; fileUrl = req.body.url;
} }
let verticalIds = req.body.verticalIds; const parseJsonOrArray = (val: any) => {
if (typeof verticalIds === 'string' && verticalIds.trim()) { if (!val) return undefined;
try { verticalIds = JSON.parse(verticalIds); } catch { verticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean); } 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'),
@ -81,6 +89,9 @@ export class AssetController {
problemStatement: req.body.problemStatement || null, problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null, solution: req.body.solution || null,
verticalIds, verticalIds,
techStackIds,
engagementTypeIds,
complianceIds,
shares, shares,
sharedOrgIds: req.body.sharedOrgIds || null, sharedOrgIds: req.body.sharedOrgIds || null,
}; };
@ -96,6 +107,9 @@ export class AssetController {
const filters = { const filters = {
search: req.query.search as string, search: req.query.search as string,
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined, 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, contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
subcategories: req.query.subcategories ? (req.query.subcategories 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, tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,

View File

@ -19,15 +19,30 @@ export class TaxonomyController {
} catch (err) { next(err); } } catch (err) { next(err); }
}; };
// Public/Authenticated: Get taxonomy metadata with real-time asset counts // Public/Authenticated: Get taxonomy metadata with real-time asset counts across all 4 groups
public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => { public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const [verticals, assets] = await Promise.all([ const [verticals, techStacks, engagementTypes, complianceStandards, assets] = await Promise.all([
prisma.vertical.findMany({ prisma.vertical.findMany({
where: { isActive: true }, where: { isActive: true },
include: { _count: { select: { assets: true } } }, include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' }, 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({ prisma.asset.findMany({
select: { select: {
categoryId: true, categoryId: true,
@ -62,6 +77,9 @@ export class TaxonomyController {
res.status(200).json({ res.status(200).json({
verticals, verticals,
techStacks,
engagementTypes,
complianceStandards,
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })), categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })), subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })), contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
@ -117,4 +135,73 @@ export class TaxonomyController {
res.status(204).send(); res.status(204).send();
} catch (err) { next(err); } } 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

@ -12,4 +12,13 @@ router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createV
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical); router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical); 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; export default router;

View File

@ -55,7 +55,7 @@ export class AssetService {
} }
public async createAsset(data: any) { public async createAsset(data: any) {
const { sharedOrgIds, shares, tags, verticalIds, ...rest } = data; const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
// Parse tags // Parse tags
let parsedTags: string[] = []; let parsedTags: string[] = [];
@ -69,27 +69,36 @@ export class AssetService {
} }
} }
// Parse verticalIds const parseIds = (val: any): string[] => {
let parsedVerticalIds: string[] = []; if (Array.isArray(val)) return val;
if (Array.isArray(verticalIds)) { if (typeof val === 'string' && val.trim()) {
parsedVerticalIds = verticalIds; try { return JSON.parse(val); }
} else if (typeof verticalIds === 'string' && verticalIds.trim()) { catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
try {
parsedVerticalIds = JSON.parse(verticalIds);
} catch {
parsedVerticalIds = verticalIds.split(',').map((id: string) => id.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 ? { ...(parsedVerticalIds.length > 0 ? {
verticals: { verticals: { connect: parsedVerticalIds.map(id => ({ id })) }
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 })) }
} : {}),
} }
}); });
@ -160,6 +169,9 @@ export class AssetService {
filters?: { filters?: {
search?: string; search?: string;
verticalIds?: string[]; verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[]; contentTypes?: string[];
subcategories?: string[]; subcategories?: string[];
tags?: string[]; tags?: string[];
@ -235,9 +247,31 @@ export class AssetService {
if (filters?.verticalIds && filters.verticalIds.length > 0) { if (filters?.verticalIds && filters.verticalIds.length > 0) {
andConditions.push({ andConditions.push({
verticals: { verticals: {
some: { some: { id: { in: filters.verticalIds } }
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 } }
} }
}); });
} }
@ -275,6 +309,9 @@ export class AssetService {
where: whereClause, where: whereClause,
include: { include: {
verticals: true, verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -301,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: {
@ -323,7 +364,7 @@ export class AssetService {
} }
public async updateAsset(id: string, data: any) { public async updateAsset(id: string, data: any) {
const { shares, sharedOrgIds, tags, verticalIds, ...rest } = data; const { shares, sharedOrgIds, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
const updateData: any = { ...rest }; const updateData: any = { ...rest };
@ -341,20 +382,26 @@ export class AssetService {
updateData.tags = parsedTags; updateData.tags = parsedTags;
} }
if (verticalIds !== undefined) { const parseIds = (val: any): string[] => {
let parsedVerticalIds: string[] = []; if (Array.isArray(val)) return val;
if (Array.isArray(verticalIds)) { if (typeof val === 'string') {
parsedVerticalIds = verticalIds; try { return JSON.parse(val); }
} else if (typeof verticalIds === 'string') { catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
try {
parsedVerticalIds = JSON.parse(verticalIds);
} catch {
parsedVerticalIds = verticalIds.split(',').map((v: string) => v.trim()).filter(Boolean);
}
} }
updateData.verticals = { return [];
set: parsedVerticalIds.map(vid => ({ id: vid })) };
};
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({

View File

@ -0,0 +1,108 @@
import prisma from './db';
export async function seedFourGroupTaxonomy() {
console.log('[Taxonomy Seeder] Starting 4-Group Taxonomy Seeding...');
// 1. Group 1: Industry Verticals (AI & ML removed to prevent double-counting)
const verticalsData = [
{ name: 'Cybersecurity', slug: 'cybersecurity', color: '#3b82f6', description: 'OT, Infrastructure & Data Defense' },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' },
{ name: 'Finance & Banking', slug: 'finance-banking', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' },
{ name: 'Insurance', slug: 'insurance', color: '#6366f1', description: 'InsurTech, Claims & Underwriting' },
{ name: 'Energy & Utilities', slug: 'energy-utilities', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' },
{ name: 'Agriculture', slug: 'agriculture', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' },
{ name: 'Education', slug: 'education', color: '#8b5cf6', description: 'EdTech, LMS & Digital Classrooms' },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', color: '#06b6d4', description: 'Industry 4.0, Robotics & Sensors' },
{ name: 'Automotive', slug: 'automotive', color: '#ef4444', description: 'EV, Autonomous Systems & Fleet' },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', color: '#f97316', description: 'E-Commerce, Logistics & Inventory' },
{ name: 'Blockchain', slug: 'blockchain', color: '#14b8a6', description: 'Web3, Smart Contracts & Distributed Ledgers' },
];
for (const [idx, v] of verticalsData.entries()) {
await prisma.vertical.upsert({
where: { slug: v.slug },
update: { name: v.name, color: v.color, description: v.description, orderIndex: idx, isActive: true },
create: { name: v.name, slug: v.slug, color: v.color, description: v.description, orderIndex: idx, isActive: true },
});
}
// Deactivate old legacy "AI & ML" entry if present in Verticals table to prevent double counting
await prisma.vertical.updateMany({
where: { slug: 'ai-ml' },
data: { isActive: false }
}).catch(() => {});
// 2. Group 2: Technology Stack
const techStacksData = [
// Languages & Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', color: '#f97316' },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', color: '#22c55e' },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', color: '#3b82f6' },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', color: '#06b6d4' },
{ name: 'Go', slug: 'go', category: 'Languages & Frameworks', color: '#00add8' },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', color: '#512bd4' },
// AI & ML
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', color: '#8b5cf6' },
{ name: 'RAG Systems', slug: 'rag-systems', category: 'AI & ML', color: '#a855f7' },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', color: '#d946ef' },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', color: '#ec4899' },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', color: '#f43f5e' },
// Data & Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', color: '#336791' },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', color: '#111827' },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', color: '#231f20' },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', color: '#eab308' },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', color: '#10b981' },
// Cloud & Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', color: '#ff9900' },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', color: '#64748b' },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', color: '#326ce5' },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', color: '#0284c7' },
];
for (const [idx, t] of techStacksData.entries()) {
await prisma.techStack.upsert({
where: { slug: t.slug },
update: { name: t.name, category: t.category, color: t.color, orderIndex: idx, isActive: true },
create: { name: t.name, slug: t.slug, category: t.category, color: t.color, orderIndex: idx, isActive: true },
});
}
// 3. Group 3: Engagement Type
const engagementData = [
{ name: 'Build', slug: 'build', description: 'Greenfield architecture & end-to-end development', color: '#3b82f6' },
{ name: 'Rescue', slug: 'rescue', description: 'Turnaround, refactoring & distressed project recovery', color: '#ef4444' },
{ name: 'Scale', slug: 'scale', description: 'Performance tuning, cloud expansion & enterprise scaling', color: '#10b981' },
{ name: 'Due Diligence', slug: 'due-diligence', description: 'Tech audit, risk assessment & code quality review', color: '#f59e0b' },
];
for (const [idx, e] of engagementData.entries()) {
await prisma.engagementType.upsert({
where: { slug: e.slug },
update: { name: e.name, description: e.description, color: e.color, orderIndex: idx, isActive: true },
create: { name: e.name, slug: e.slug, description: e.description, color: e.color, orderIndex: idx, isActive: true },
});
}
// 4. Group 4: Compliance & Regulatory
const complianceData = [
{ name: 'HIPAA', slug: 'hipaa', description: 'Health Insurance Portability and Accountability Act', color: '#ec4899' },
{ name: 'GxP', slug: 'gxp', description: 'Good Practice regulations (FDA/EMEA Pharma)', color: '#8b5cf6' },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', description: 'Operational Risk Management (Australian Prudential)', color: '#3b82f6' },
{ name: 'SOC 2', slug: 'soc-2', description: 'Security, Availability, and Confidentiality Trust Criteria', color: '#10b981' },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', description: 'EU General Data Protection & Data Sovereignty', color: '#06b6d4' },
];
for (const [idx, c] of complianceData.entries()) {
await prisma.complianceStandard.upsert({
where: { slug: c.slug },
update: { name: c.name, description: c.description, color: c.color, orderIndex: idx, isActive: true },
create: { name: c.name, slug: c.slug, description: c.description, color: c.color, orderIndex: idx, isActive: true },
});
}
console.log('[Taxonomy Seeder] 4-Group Taxonomy Seeding completed successfully.');
}

View File

@ -1,7 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Shield, Plus, Trash2, Send, Tag, CheckCircle2, AlertCircle, Bell } from 'lucide-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 type { TaxonomyMeta, Organization, Asset } from '../../../types/assets';
import { createVertical, deleteVertical, sendAssetAnnouncement } from '../../../services/assets-api'; import {
createVertical, deleteVertical,
createTechStack, deleteTechStack,
createEngagementType, deleteEngagementType,
createComplianceStandard, deleteComplianceStandard,
sendAssetAnnouncement
} from '../../../services/assets-api';
interface AssetAdminManagerModalProps { interface AssetAdminManagerModalProps {
isOpen: boolean; isOpen: boolean;
@ -20,18 +26,31 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
allAssets = [], allAssets = [],
onRefreshMeta, onRefreshMeta,
}) => { }) => {
const [activeTab, setActiveTab] = useState<'verticals' | 'taxonomy' | 'announcements'>('verticals'); const [activeTab, setActiveTab] = useState<'verticals' | 'techStacks' | 'engagements' | 'compliance' | 'taxonomy' | 'announcements'>('verticals');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Inspect filter state for Taxonomy Stats interactive list // Inspect filter state for Taxonomy Stats interactive list
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null); const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
// New Vertical Form State // Form State: Verticals
const [newVerticalName, setNewVerticalName] = useState(''); const [newVerticalName, setNewVerticalName] = useState('');
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6'); const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
const [newVerticalDesc, setNewVerticalDesc] = useState(''); 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 // Announcement Form State
const [announcementTitle, setAnnouncementTitle] = useState(''); const [announcementTitle, setAnnouncementTitle] = useState('');
const [announcementMsg, setAnnouncementMsg] = useState(''); const [announcementMsg, setAnnouncementMsg] = useState('');
@ -50,6 +69,7 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
return false; return false;
}); });
// Vertical CRUD
const handleCreateVertical = async (e: React.FormEvent) => { const handleCreateVertical = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newVerticalName.trim()) return; if (!newVerticalName.trim()) return;
@ -86,6 +106,109 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
} }
}; };
// 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) => { const handleSendAnnouncement = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!announcementTitle.trim() || !announcementMsg.trim()) return; if (!announcementTitle.trim() || !announcementMsg.trim()) return;
@ -108,7 +231,7 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
return ( 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="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-3xl overflow-hidden flex flex-col max-h-[85vh]"> <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 */} {/* 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="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">
@ -118,10 +241,10 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100"> <h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Taxonomy & Partner Announcements Control Panel Taxonomy & Announcements Control Panel
</h2> </h2>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Manage industry verticals, taxonomy metadata, and partner communications Manage 4-Group Taxonomy items, metadata stats, and partner notifications
</p> </p>
</div> </div>
</div> </div>
@ -134,39 +257,77 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
</div> </div>
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="flex border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-800/50 px-6"> <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 <button
onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }} onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${ className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'verticals' activeTab === 'verticals'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100' ? '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' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
> >
<Shield className="w-4 h-4" /> <Shield className="w-3.5 h-3.5" />
Industry Verticals ({meta?.verticals.length || 0}) 1. Verticals ({meta?.verticals.length || 0})
</button> </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 <button
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }} onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${ className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'taxonomy' activeTab === 'taxonomy'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100' ? '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' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
> >
<Tag className="w-4 h-4" /> <Tag className="w-3.5 h-3.5" />
Taxonomy Stats ({meta?.totalAssets || 0} Assets) Stats ({meta?.totalAssets || 0})
</button> </button>
<button <button
onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }} onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }}
className={`px-4 py-3 text-xs font-bold border-b-2 flex items-center gap-2 transition-colors ${ className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'announcements' activeTab === 'announcements'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100' ? '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' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
> >
<Bell className="w-4 h-4" /> <Bell className="w-3.5 h-3.5" />
Partner Announcements Announcements
</button> </button>
</div> </div>
@ -284,72 +445,303 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
</div> </div>
)} )}
{/* TAB 2: Taxonomy Stats */} {/* TAB 2: Tech Stacks */}
{activeTab === 'taxonomy' && ( {activeTab === 'techStacks' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> {/* Create Tech Stack Form */}
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800"> <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">
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div> <h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<div className="text-xs text-slate-500 font-medium">Total Catalog Assets</div> <Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
</div> Add New Tech Stack / Capability Item
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800"> </h3>
<div className="text-2xl font-bold text-emerald-600">{meta?.verticals.length || 0}</div> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="text-xs text-slate-500 font-medium font-mono">Industry Verticals</div> <div>
</div> <label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800"> Tech Item Name
<div className="text-2xl font-bold text-amber-500">{meta?.subcategories.length || 0}</div> </label>
<div className="text-xs text-slate-500 font-medium">Subcategories</div> <input
</div> type="text"
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800"> placeholder="e.g. Rust, PyTorch, GraphQL"
<div className="text-2xl font-bold text-cyan-600">{meta?.tags.length || 0}</div> value={newTechName}
<div className="text-xs text-slate-500 font-medium">Unique Tags</div> onChange={e => setNewTechName(e.target.value)}
</div> 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>
<div> <div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3"> <label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Subcategory Breakdown (Click to Inspect Records) Tech Category / Group
</h4> </label>
<div className="flex flex-wrap gap-2"> <select
{(meta?.subcategories || []).map(s => ( value={newTechCategory}
<button onChange={e => setNewTechCategory(e.target.value)}
key={s.name} 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"
onClick={() => setInspectFilter({ type: 'Subcategory', name: s.name })}
className={`px-3 py-1.5 rounded-lg border text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5 ${
inspectFilter?.name === s.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-700 dark:text-slate-300 hover:border-slate-400'
}`}
> >
<span>{s.name}</span> <option value="Languages & Frameworks">Languages & Frameworks</option>
<span className="font-extrabold px-1.5 py-0.2 bg-slate-100 dark:bg-slate-700 rounded text-[10px]"> <option value="AI & ML">AI & ML</option>
{s.count} <option value="Data & Backend">Data & Backend</option>
</span> <option value="Cloud & Infra">Cloud & Infra</option>
</button> </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> </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> <div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3"> <h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
Vertical Domain Distribution 1. Industry Verticals Domain Distribution
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{(meta?.verticals || []).map(v => ( {(meta?.verticals || []).map(v => (
<button <button
key={v.id} key={v.id}
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })} onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${ className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${
inspectFilter?.name === v.name inspectFilter?.name === v.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900' ? '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' : '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.5"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} /> <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> <span className="font-bold text-xs">{v.name}</span>
</div> </div>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-700"> <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 {v._count?.assets ?? 0} assets
</span> </span>
</button> </button>
@ -357,6 +749,59 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
</div> </div>
</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 */} {/* Inspect Filter Asset List Details */}
{inspectFilter && ( {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="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">

View File

@ -14,7 +14,8 @@ import {
Clock, Clock,
AlertCircle, AlertCircle,
Globe, Globe,
Sparkles Sparkles,
Shield
} from 'lucide-react'; } from 'lucide-react';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
@ -572,19 +573,54 @@ export const AssetCard: React.FC<AssetCardProps> = ({
<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 flex-wrap items-center gap-1.5 mb-2"> <div className="flex flex-wrap items-center gap-1.5 mb-2">
{asset.verticals && asset.verticals.length > 0 && ( {/* Group 1: Industry Verticals */}
{asset.verticals && asset.verticals.map(v => (
<span <span
key={v.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold tracking-wider border shrink-0" className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold tracking-wider border shrink-0"
style={{ style={{
backgroundColor: `${asset.verticals[0].color || '#6366f1'}15`, backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${asset.verticals[0].color || '#6366f1'}40`, borderColor: `${v.color || '#3b82f6'}40`,
color: asset.verticals[0].color || '#6366f1', color: v.color || '#3b82f6',
}} }}
> >
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: asset.verticals[0].color || '#6366f1' }} /> <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{asset.verticals[0].name} {v.name}
</span> </span>
)} ))}
{/* Group 2: Tech Stack */}
{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-[10px] font-semibold text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shrink-0"
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
{t.name}
</span>
))}
{/* Group 3: Engagement Type */}
{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-[10px] 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 shrink-0"
>
{e.name}
</span>
))}
{/* Group 4: Compliance Standards */}
{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-[10px] 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 shrink-0"
>
<Shield className="w-2.5 h-2.5 text-emerald-500" />
{c.name}
</span>
))}
<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"> <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.subcategory || asset.categoryId || 'General'} {asset.subcategory || asset.categoryId || 'General'}
</div> </div>

View File

@ -50,7 +50,6 @@ export const AssetTableView: React.FC<AssetTableViewProps> = ({
{assets.map((asset) => { {assets.map((asset) => {
const isSelected = selectedIds.includes(asset.id); const isSelected = selectedIds.includes(asset.id);
const isRecommended = recommendedIds.includes(asset.id); const isRecommended = recommendedIds.includes(asset.id);
const primaryVertical = asset.verticals && asset.verticals.length > 0 ? asset.verticals[0] : null;
return ( return (
<tr <tr
@ -117,23 +116,58 @@ export const AssetTableView: React.FC<AssetTableViewProps> = ({
</div> </div>
</td> </td>
{/* Vertical */} {/* Taxonomy Classification */}
<td className="py-3 px-4"> <td className="py-3 px-4">
{primaryVertical ? ( <div className="flex flex-wrap gap-1 max-w-xs">
<span {asset.verticals && asset.verticals.map(v => (
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border" <span
style={{ key={v.id}
backgroundColor: `${primaryVertical.color}15`, className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border"
borderColor: `${primaryVertical.color}40`, style={{
color: primaryVertical.color || '#94a3b8', 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: primaryVertical.color || '#94a3b8' }} /> }}
{primaryVertical.name} >
</span> <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
) : ( {v.name}
<span className="text-slate-400 italic text-[11px]">General</span> </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> </td>
{/* Type */} {/* Type */}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { updateAsset, getVerticals } from '../../../services/assets-api'; import { updateAsset, getTaxonomyMeta } from '../../../services/assets-api';
import type { Asset, Vertical } 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,8 +19,13 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [verticals, setVerticals] = useState<Vertical[]>([]); const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [editVerticalId, setEditVerticalId] = useState<string>('');
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');
@ -31,7 +36,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => { useEffect(() => {
getVerticals().then(setVerticals).catch(console.error); getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -43,10 +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);
setEditVerticalId(asset.verticals && asset.verticals.length > 0 ? asset.verticals[0].id : '');
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;
@ -58,7 +71,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
description: editDescription, description: editDescription,
categoryId: editCategory, categoryId: editCategory,
subcategory: editSubcategory, subcategory: editSubcategory,
verticalIds: editVerticalId ? [editVerticalId] : [], 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,
@ -115,43 +131,130 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 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">Industry Vertical</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<select 1. Industry Verticals / Domains
value={editVerticalId} </label>
onChange={(e) => setEditVerticalId(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);
<option value="">General (No Domain)</option> return (
{verticals.map(v => ( <button
<option key={v.id} value={v.id}>{v.name}</option> key={v.id}
))} type="button"
</select> 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'
}`}
>
{v.name}
</button>
);
})}
</div>
</div> </div>
{/* Group 2: Tech Stack */}
<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 2. Technology Stack & Capabilities
value={editCategory} </label>
onChange={(e) => setEditCategory(e.target.value)} <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">
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" {['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
> const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
<option value="Marketing">Marketing</option> if (items.length === 0) return null;
<option value="Presentations">Presentations</option> return (
<option value="Branding">Branding</option> <div key={catName} className="space-y-1">
<option value="Resources">Resources</option> <div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
<option value="Technical">Technical</option> {catName}
</select> </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> </div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label> {/* Group 3 & Group 4 Grid */}
<input <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
type="text" {/* Group 3: Engagement Type */}
value={editSubcategory} <div>
onChange={(e) => setEditSubcategory(e.target.value)} <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
placeholder="e.g. Slide Deck" 3. Engagement Type
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" </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

@ -129,10 +129,10 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
{/* Body Content */} {/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar"> <div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
{/* 1. Industry Verticals */} {/* 1. Industry Verticals (Group 1) */}
<div> <div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between"> <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>Industry Verticals</span> <span>1. Industry Verticals</span>
<span className="text-[10px] lowercase font-normal text-slate-400"> <span className="text-[10px] lowercase font-normal text-slate-400">
({filteredVerticals.length}) ({filteredVerticals.length})
</span> </span>
@ -189,7 +189,130 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
</div> </div>
</div> </div>
{/* 2. Content Types */} {/* 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> <div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3"> <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Content Types Content Types
@ -237,7 +360,7 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
</div> </div>
</div> </div>
{/* 3. Subcategories */} {/* 6. Subcategories */}
{filteredSubcategories.length > 0 && ( {filteredSubcategories.length > 0 && (
<div> <div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3"> <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
@ -269,7 +392,7 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
</div> </div>
)} )}
{/* 4. Tag Cloud */} {/* 7. Tag Cloud */}
{filteredTags.length > 0 && ( {filteredTags.length > 0 && (
<div> <div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3"> <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">

View File

@ -1,7 +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 } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy, getVerticals } from '../../../services/assets-api'; import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta } from '../../../services/assets-api';
import type { Vertical } from '../../../types/assets'; import type { 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';
@ -18,8 +18,12 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast(); const { success, error } = useToast();
const [verticals, setVerticals] = useState<Vertical[]>([]); const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [selectedVerticalId, setSelectedVerticalId] = 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);
@ -41,7 +45,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
useEffect(() => { useEffect(() => {
getVerticals().then(setVerticals).catch(console.error); getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -158,8 +162,17 @@ 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 (selectedVerticalId) { if (selectedVerticalIds.length) {
formData.append('verticalIds', JSON.stringify([selectedVerticalId])); 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) {
@ -184,6 +197,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();
@ -196,6 +213,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
@ -466,43 +487,130 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 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">Industry Vertical / Domain</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<select 1. Industry Verticals / Domains
value={selectedVerticalId} </label>
onChange={(e) => setSelectedVerticalId(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);
<option value="">General (No Domain)</option> return (
{verticals.map(v => ( <button
<option key={v.id} value={v.id}>{v.name}</option> key={v.id}
))} type="button"
</select> 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'
}`}
>
{v.name}
</button>
);
})}
</div>
</div> </div>
{/* Group 2: Tech Stack */}
<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 2. Technology Stack & Capabilities
value={uploadCategory} </label>
onChange={(e) => setUploadCategory(e.target.value)} <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">
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" {['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
> const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
<option value="Marketing">Marketing</option> if (items.length === 0) return null;
<option value="Presentations">Presentations</option> return (
<option value="Branding">Branding</option> <div key={catName} className="space-y-1">
<option value="Resources">Resources</option> <div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
<option value="Technical">Technical</option> {catName}
</select> </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> </div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label> {/* Group 3 & Group 4 Grid */}
<input <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
type="text" {/* Group 3: Engagement Type */}
value={uploadSubcategory} <div>
onChange={(e) => setUploadSubcategory(e.target.value)} <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
placeholder="e.g. Slide Deck" 3. Engagement Type
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" </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>

View File

@ -314,7 +314,7 @@ export const AssetsPage = () => {
const toolbarNode = ( const toolbarNode = (
<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 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-wrap items-center gap-3 flex-1 min-w-0"> <div className="flex flex-wrap items-center gap-3 flex-1 min-w-0">
{/* Search Bar */} {/* Search Bar */}
<div className="relative w-full sm:w-[280px] shrink-0"> <div className="relative w-full sm:w-[280px] shrink-0">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
@ -347,33 +347,30 @@ export const AssetsPage = () => {
<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"> <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 <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md text-xs transition-all ${ className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'grid'
viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold' ? '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' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
title="Grid Cards" title="Grid Cards"
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('compact')} onClick={() => setViewMode('compact')}
className={`p-1.5 rounded-md text-xs transition-all ${ className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'compact'
viewMode === 'compact'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold' ? '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' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
title="Compact Cards" title="Compact Cards"
> >
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" /> <LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
</button> </button>
<button <button
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
className={`p-1.5 rounded-md text-xs transition-all ${ className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'table'
viewMode === 'table'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold' ? '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' : 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`} }`}
title="Tabular List View (Shift+Click)" title="Tabular List View (Shift+Click)"
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
@ -385,21 +382,19 @@ export const AssetsPage = () => {
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl 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 py-1 rounded-lg text-xs font-semibold transition-all ${ className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL"
selectedCategory === "ALL"
? "bg-slate-900 text-white shadow-xs" ? "bg-slate-900 text-white shadow-xs"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200" : "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 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${ className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${selectedCategory === "RECOMMENDED"
selectedCategory === "RECOMMENDED"
? "bg-amber-500 text-slate-950 font-bold shadow-xs" ? "bg-amber-500 text-slate-950 font-bold shadow-xs"
: "text-amber-600 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" /> <Sparkles className="w-3.5 h-3.5" />
<span>Recommended ({recommendedAssets.length})</span> <span>Recommended ({recommendedAssets.length})</span>
@ -498,11 +493,10 @@ export const AssetsPage = () => {
{/* Filter Drawer Trigger Button positioned on the far right end */} {/* Filter Drawer Trigger Button positioned on the far right end */}
<button <button
onClick={() => setIsFilterDrawerOpen(true)} onClick={() => setIsFilterDrawerOpen(true)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${ className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${activeFilterCount > 0
activeFilterCount > 0
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900" ? "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" : "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" /> <Filter className="w-4 h-4" />
<span>Filters</span> <span>Filters</span>
@ -526,7 +520,7 @@ export const AssetsPage = () => {
<span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span> <span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span>
{selectedAssetIds.length > 0 && ( {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"> <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 (Hold Shift to range-select) {selectedAssetIds.length} selected
</span> </span>
)} )}
</div> </div>
@ -583,11 +577,10 @@ export const AssetsPage = () => {
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="show" animate="show"
className={`grid grid-cols-1 ${ className={`grid grid-cols-1 ${viewMode === 'compact'
viewMode === 'compact'
? 'md:grid-cols-3 xl:grid-cols-5 gap-3' ? 'md:grid-cols-3 xl:grid-cols-5 gap-3'
: 'md:grid-cols-2 xl:grid-cols-4 gap-4' : 'md:grid-cols-2 xl:grid-cols-4 gap-4'
} items-start`} } items-start`}
> >
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<motion.div <motion.div

View File

@ -1,10 +1,13 @@
import { axiosInstance } from './axios'; import { axiosInstance } from './axios';
import type { Asset, Organization, Vertical, TaxonomyMeta, AssetQueryFilters } from '../types/assets'; import type { Asset, Organization, Vertical, TechStack, EngagementType, ComplianceStandard, TaxonomyMeta, AssetQueryFilters } from '../types/assets';
export const getAssets = async (filters?: AssetQueryFilters): Promise<Asset[]> => { export const getAssets = async (filters?: AssetQueryFilters): Promise<Asset[]> => {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (filters?.search) params.search = filters.search; if (filters?.search) params.search = filters.search;
if (filters?.verticalIds?.length) params.verticalIds = filters.verticalIds.join(','); 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?.contentTypes?.length) params.contentTypes = filters.contentTypes.join(',');
if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(','); if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(',');
if (filters?.tags?.length) params.tags = filters.tags.join(','); if (filters?.tags?.length) params.tags = filters.tags.join(',');
@ -33,6 +36,9 @@ export const updateAsset = async (
categoryId?: string; categoryId?: string;
subcategory?: string; subcategory?: string;
verticalIds?: string[]; verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
tags?: string[]; tags?: string[];
githubUrl?: string; githubUrl?: string;
isDownloadable?: boolean; isDownloadable?: boolean;
@ -139,6 +145,33 @@ export const deleteVertical = async (id: string): Promise<void> => {
await axiosInstance.delete(`/taxonomy/verticals/${id}`); 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 // Notification API
export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => { export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => {
await axiosInstance.post('/assets/notify', payload); await axiosInstance.post('/assets/notify', payload);

View File

@ -40,8 +40,54 @@ export interface Vertical {
}; };
} }
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 { export interface TaxonomyMeta {
verticals: Vertical[]; verticals: Vertical[];
techStacks: TechStack[];
engagementTypes: EngagementType[];
complianceStandards: ComplianceStandard[];
categories: { name: string; count: number }[]; categories: { name: string; count: number }[];
subcategories: { name: string; count: number }[]; subcategories: { name: string; count: number }[];
contentTypes: { name: string; count: number }[]; contentTypes: { name: string; count: number }[];
@ -52,6 +98,9 @@ export interface TaxonomyMeta {
export interface AssetQueryFilters { export interface AssetQueryFilters {
search?: string; search?: string;
verticalIds?: string[]; verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[]; contentTypes?: string[];
subcategories?: string[]; subcategories?: string[];
tags?: string[]; tags?: string[];
@ -80,6 +129,9 @@ export interface Asset {
solution?: string | null; solution?: string | null;
createdAt: string; createdAt: string;
verticals?: Vertical[]; verticals?: Vertical[];
techStacks?: TechStack[];
engagementTypes?: EngagementType[];
complianceStandards?: ComplianceStandard[];
sharedWith?: SharedWithOrg[]; sharedWith?: SharedWithOrg[];
downloadRequests?: DownloadRequest[]; downloadRequests?: DownloadRequest[];
} }

File diff suppressed because it is too large Load Diff

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