Valid_filters_and_texanomy
This commit is contained in:
parent
61f537057b
commit
a127b5ffc4
4
.gitignore
vendored
4
.gitignore
vendored
@ -35,4 +35,6 @@ uploads/*
|
||||
/memory.md
|
||||
/phases.md
|
||||
/architecture.md
|
||||
/Guide.md
|
||||
/Guide.md
|
||||
# Dedicated Documentation Folder
|
||||
/documents/
|
||||
|
||||
@ -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();
|
||||
@ -78,13 +78,16 @@ model Asset {
|
||||
solution String? @db.Text
|
||||
contentType String?
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
verticals Vertical[] @relation("AssetVerticals")
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
verticals Vertical[] @relation("AssetVerticals")
|
||||
techStacks TechStack[] @relation("AssetTechStacks")
|
||||
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
|
||||
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
|
||||
sharedWith SharedAsset[]
|
||||
downloadRequests DownloadRequest[]
|
||||
assetGroups AssetGroup[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Vertical {
|
||||
@ -101,6 +104,49 @@ model Vertical {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TechStack {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
category String // "Languages & Frameworks", "AI & ML", "Data & Backend", "Cloud & Infra"
|
||||
icon String?
|
||||
description String?
|
||||
color String?
|
||||
orderIndex Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
assets Asset[] @relation("AssetTechStacks")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model EngagementType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
icon String?
|
||||
description String?
|
||||
color String?
|
||||
orderIndex Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
assets Asset[] @relation("AssetEngagementTypes")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ComplianceStandard {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
icon String?
|
||||
description String?
|
||||
color String?
|
||||
orderIndex Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
assets Asset[] @relation("AssetComplianceStandards")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AssetNotification {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
|
||||
@ -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());
|
||||
@ -2,6 +2,7 @@ import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import prisma from './src/utils/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { seedFourGroupTaxonomy } from './src/utils/seed-taxonomy';
|
||||
|
||||
async function seed() {
|
||||
console.log('Starting database seeding...');
|
||||
@ -197,6 +198,9 @@ async function seed() {
|
||||
}
|
||||
console.log('Seeded Ecosystem Offerings.');
|
||||
|
||||
// 6. Seed 4-Group Asset Taxonomy
|
||||
await seedFourGroupTaxonomy();
|
||||
|
||||
console.log('Seeding completed successfully.');
|
||||
}
|
||||
|
||||
|
||||
@ -59,10 +59,18 @@ export class AssetController {
|
||||
fileUrl = req.body.url;
|
||||
}
|
||||
|
||||
let verticalIds = req.body.verticalIds;
|
||||
if (typeof verticalIds === 'string' && verticalIds.trim()) {
|
||||
try { verticalIds = JSON.parse(verticalIds); } catch { verticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean); }
|
||||
}
|
||||
const parseJsonOrArray = (val: any) => {
|
||||
if (!val) return undefined;
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
try { return JSON.parse(val); } catch { return val.split(',').map((id: string) => id.trim()).filter(Boolean); }
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
let verticalIds = parseJsonOrArray(req.body.verticalIds);
|
||||
let techStackIds = parseJsonOrArray(req.body.techStackIds);
|
||||
let engagementTypeIds = parseJsonOrArray(req.body.engagementTypeIds);
|
||||
let complianceIds = parseJsonOrArray(req.body.complianceIds);
|
||||
|
||||
const assetData = {
|
||||
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
|
||||
@ -81,6 +89,9 @@ export class AssetController {
|
||||
problemStatement: req.body.problemStatement || null,
|
||||
solution: req.body.solution || null,
|
||||
verticalIds,
|
||||
techStackIds,
|
||||
engagementTypeIds,
|
||||
complianceIds,
|
||||
shares,
|
||||
sharedOrgIds: req.body.sharedOrgIds || null,
|
||||
};
|
||||
@ -96,6 +107,9 @@ export class AssetController {
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined,
|
||||
techStackIds: req.query.techStackIds ? (req.query.techStackIds as string).split(',') : undefined,
|
||||
engagementTypeIds: req.query.engagementTypeIds ? (req.query.engagementTypeIds as string).split(',') : undefined,
|
||||
complianceIds: req.query.complianceIds ? (req.query.complianceIds as string).split(',') : undefined,
|
||||
contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
|
||||
subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined,
|
||||
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
|
||||
|
||||
@ -19,15 +19,30 @@ export class TaxonomyController {
|
||||
} 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) => {
|
||||
try {
|
||||
const [verticals, assets] = await Promise.all([
|
||||
const [verticals, techStacks, engagementTypes, complianceStandards, assets] = await Promise.all([
|
||||
prisma.vertical.findMany({
|
||||
where: { isActive: true },
|
||||
include: { _count: { select: { assets: true } } },
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
}),
|
||||
prisma.techStack.findMany({
|
||||
where: { isActive: true },
|
||||
include: { _count: { select: { assets: true } } },
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
}),
|
||||
prisma.engagementType.findMany({
|
||||
where: { isActive: true },
|
||||
include: { _count: { select: { assets: true } } },
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
}),
|
||||
prisma.complianceStandard.findMany({
|
||||
where: { isActive: true },
|
||||
include: { _count: { select: { assets: true } } },
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
}),
|
||||
prisma.asset.findMany({
|
||||
select: {
|
||||
categoryId: true,
|
||||
@ -62,6 +77,9 @@ export class TaxonomyController {
|
||||
|
||||
res.status(200).json({
|
||||
verticals,
|
||||
techStacks,
|
||||
engagementTypes,
|
||||
complianceStandards,
|
||||
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
|
||||
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
|
||||
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
|
||||
@ -117,4 +135,73 @@ export class TaxonomyController {
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Create Tech Stack
|
||||
public createTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, category, icon, description, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||
|
||||
const item = await prisma.techStack.create({
|
||||
data: { name, slug, category: category || 'Languages & Frameworks', icon, description, color: color || '#64748b' }
|
||||
});
|
||||
res.status(201).json(item);
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Delete Tech Stack
|
||||
public deleteTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.techStack.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Create Engagement Type
|
||||
public createEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, icon, description, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||
|
||||
const item = await prisma.engagementType.create({
|
||||
data: { name, slug, icon, description, color: color || '#0284c7' }
|
||||
});
|
||||
res.status(201).json(item);
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Delete Engagement Type
|
||||
public deleteEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.engagementType.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Create Compliance Standard
|
||||
public createComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, icon, description, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||
|
||||
const item = await prisma.complianceStandard.create({
|
||||
data: { name, slug, icon, description, color: color || '#10b981' }
|
||||
});
|
||||
res.status(201).json(item);
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
|
||||
// Admin: Delete Compliance Standard
|
||||
public deleteComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.complianceStandard.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,4 +12,13 @@ router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createV
|
||||
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
|
||||
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical);
|
||||
|
||||
router.post('/tech-stacks', authenticate, requireRole('ADMIN'), controller.createTechStack);
|
||||
router.delete('/tech-stacks/:id', authenticate, requireRole('ADMIN'), controller.deleteTechStack);
|
||||
|
||||
router.post('/engagement-types', authenticate, requireRole('ADMIN'), controller.createEngagementType);
|
||||
router.delete('/engagement-types/:id', authenticate, requireRole('ADMIN'), controller.deleteEngagementType);
|
||||
|
||||
router.post('/compliance-standards', authenticate, requireRole('ADMIN'), controller.createComplianceStandard);
|
||||
router.delete('/compliance-standards/:id', authenticate, requireRole('ADMIN'), controller.deleteComplianceStandard);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -55,7 +55,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async createAsset(data: any) {
|
||||
const { sharedOrgIds, shares, tags, verticalIds, ...rest } = data;
|
||||
const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
|
||||
|
||||
// Parse tags
|
||||
let parsedTags: string[] = [];
|
||||
@ -69,27 +69,36 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse verticalIds
|
||||
let parsedVerticalIds: string[] = [];
|
||||
if (Array.isArray(verticalIds)) {
|
||||
parsedVerticalIds = verticalIds;
|
||||
} else if (typeof verticalIds === 'string' && verticalIds.trim()) {
|
||||
try {
|
||||
parsedVerticalIds = JSON.parse(verticalIds);
|
||||
} catch {
|
||||
parsedVerticalIds = verticalIds.split(',').map((id: string) => id.trim()).filter(Boolean);
|
||||
const parseIds = (val: any): string[] => {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
try { return JSON.parse(val); }
|
||||
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const parsedVerticalIds = parseIds(verticalIds);
|
||||
const parsedTechStackIds = parseIds(techStackIds);
|
||||
const parsedEngagementTypeIds = parseIds(engagementTypeIds);
|
||||
const parsedComplianceIds = parseIds(complianceIds);
|
||||
|
||||
const asset = await prisma.asset.create({
|
||||
data: {
|
||||
...rest,
|
||||
tags: parsedTags,
|
||||
...(parsedVerticalIds.length > 0 ? {
|
||||
verticals: {
|
||||
connect: parsedVerticalIds.map(id => ({ id }))
|
||||
}
|
||||
} : {})
|
||||
verticals: { connect: parsedVerticalIds.map(id => ({ id })) }
|
||||
} : {}),
|
||||
...(parsedTechStackIds.length > 0 ? {
|
||||
techStacks: { connect: parsedTechStackIds.map(id => ({ id })) }
|
||||
} : {}),
|
||||
...(parsedEngagementTypeIds.length > 0 ? {
|
||||
engagementTypes: { connect: parsedEngagementTypeIds.map(id => ({ id })) }
|
||||
} : {}),
|
||||
...(parsedComplianceIds.length > 0 ? {
|
||||
complianceStandards: { connect: parsedComplianceIds.map(id => ({ id })) }
|
||||
} : {}),
|
||||
}
|
||||
});
|
||||
|
||||
@ -160,6 +169,9 @@ export class AssetService {
|
||||
filters?: {
|
||||
search?: string;
|
||||
verticalIds?: string[];
|
||||
techStackIds?: string[];
|
||||
engagementTypeIds?: string[];
|
||||
complianceIds?: string[];
|
||||
contentTypes?: string[];
|
||||
subcategories?: string[];
|
||||
tags?: string[];
|
||||
@ -235,9 +247,31 @@ export class AssetService {
|
||||
if (filters?.verticalIds && filters.verticalIds.length > 0) {
|
||||
andConditions.push({
|
||||
verticals: {
|
||||
some: {
|
||||
id: { in: filters.verticalIds }
|
||||
}
|
||||
some: { id: { in: filters.verticalIds } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.techStackIds && filters.techStackIds.length > 0) {
|
||||
andConditions.push({
|
||||
techStacks: {
|
||||
some: { id: { in: filters.techStackIds } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.engagementTypeIds && filters.engagementTypeIds.length > 0) {
|
||||
andConditions.push({
|
||||
engagementTypes: {
|
||||
some: { id: { in: filters.engagementTypeIds } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.complianceIds && filters.complianceIds.length > 0) {
|
||||
andConditions.push({
|
||||
complianceStandards: {
|
||||
some: { id: { in: filters.complianceIds } }
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -275,6 +309,9 @@ export class AssetService {
|
||||
where: whereClause,
|
||||
include: {
|
||||
verticals: true,
|
||||
techStacks: true,
|
||||
engagementTypes: true,
|
||||
complianceStandards: true,
|
||||
sharedWith: {
|
||||
include: {
|
||||
organization: {
|
||||
@ -301,6 +338,10 @@ export class AssetService {
|
||||
return await prisma.asset.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
verticals: true,
|
||||
techStacks: true,
|
||||
engagementTypes: true,
|
||||
complianceStandards: true,
|
||||
sharedWith: {
|
||||
include: {
|
||||
organization: {
|
||||
@ -323,7 +364,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@ -341,20 +382,26 @@ export class AssetService {
|
||||
updateData.tags = parsedTags;
|
||||
}
|
||||
|
||||
if (verticalIds !== undefined) {
|
||||
let parsedVerticalIds: string[] = [];
|
||||
if (Array.isArray(verticalIds)) {
|
||||
parsedVerticalIds = verticalIds;
|
||||
} else if (typeof verticalIds === 'string') {
|
||||
try {
|
||||
parsedVerticalIds = JSON.parse(verticalIds);
|
||||
} catch {
|
||||
parsedVerticalIds = verticalIds.split(',').map((v: string) => v.trim()).filter(Boolean);
|
||||
}
|
||||
const parseIds = (val: any): string[] => {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); }
|
||||
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
|
||||
}
|
||||
updateData.verticals = {
|
||||
set: parsedVerticalIds.map(vid => ({ id: vid }))
|
||||
};
|
||||
return [];
|
||||
};
|
||||
|
||||
if (verticalIds !== undefined) {
|
||||
updateData.verticals = { set: parseIds(verticalIds).map(vid => ({ id: vid })) };
|
||||
}
|
||||
if (techStackIds !== undefined) {
|
||||
updateData.techStacks = { set: parseIds(techStackIds).map(tid => ({ id: tid })) };
|
||||
}
|
||||
if (engagementTypeIds !== undefined) {
|
||||
updateData.engagementTypes = { set: parseIds(engagementTypeIds).map(eid => ({ id: eid })) };
|
||||
}
|
||||
if (complianceIds !== undefined) {
|
||||
updateData.complianceStandards = { set: parseIds(complianceIds).map(cid => ({ id: cid })) };
|
||||
}
|
||||
|
||||
await prisma.asset.update({
|
||||
|
||||
108
Channel-Backend/src/utils/seed-taxonomy.ts
Normal file
108
Channel-Backend/src/utils/seed-taxonomy.ts
Normal 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.');
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
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 { createVertical, deleteVertical, sendAssetAnnouncement } from '../../../services/assets-api';
|
||||
import {
|
||||
createVertical, deleteVertical,
|
||||
createTechStack, deleteTechStack,
|
||||
createEngagementType, deleteEngagementType,
|
||||
createComplianceStandard, deleteComplianceStandard,
|
||||
sendAssetAnnouncement
|
||||
} from '../../../services/assets-api';
|
||||
|
||||
interface AssetAdminManagerModalProps {
|
||||
isOpen: boolean;
|
||||
@ -20,18 +26,31 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
allAssets = [],
|
||||
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 [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Inspect filter state for Taxonomy Stats interactive list
|
||||
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
|
||||
|
||||
// New Vertical Form State
|
||||
// Form State: Verticals
|
||||
const [newVerticalName, setNewVerticalName] = useState('');
|
||||
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
|
||||
const [newVerticalDesc, setNewVerticalDesc] = useState('');
|
||||
|
||||
// Form State: Tech Stacks
|
||||
const [newTechName, setNewTechName] = useState('');
|
||||
const [newTechCategory, setNewTechCategory] = useState('Languages & Frameworks');
|
||||
const [newTechColor, setNewTechColor] = useState('#64748b');
|
||||
|
||||
// Form State: Engagements
|
||||
const [newEngagementName, setNewEngagementName] = useState('');
|
||||
const [newEngagementColor, setNewEngagementColor] = useState('#0284c7');
|
||||
|
||||
// Form State: Compliance
|
||||
const [newComplianceName, setNewComplianceName] = useState('');
|
||||
const [newComplianceColor, setNewComplianceColor] = useState('#10b981');
|
||||
|
||||
// Announcement Form State
|
||||
const [announcementTitle, setAnnouncementTitle] = useState('');
|
||||
const [announcementMsg, setAnnouncementMsg] = useState('');
|
||||
@ -50,6 +69,7 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
return false;
|
||||
});
|
||||
|
||||
// Vertical CRUD
|
||||
const handleCreateVertical = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!announcementTitle.trim() || !announcementMsg.trim()) return;
|
||||
@ -108,7 +231,7 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full max-w-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 */}
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
Taxonomy & Partner Announcements Control Panel
|
||||
Taxonomy & Announcements Control Panel
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,39 +257,77 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Industry Verticals ({meta?.verticals.length || 0})
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
1. Verticals ({meta?.verticals.length || 0})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveTab('techStacks'); setStatusMsg(null); }}
|
||||
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
|
||||
activeTab === 'techStacks'
|
||||
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" />
|
||||
2. Tech Stack ({meta?.techStacks?.length || 0})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveTab('engagements'); setStatusMsg(null); }}
|
||||
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
|
||||
activeTab === 'engagements'
|
||||
? 'border-sky-600 text-sky-600 dark:border-sky-400 dark:text-sky-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
3. Engagements ({meta?.engagementTypes?.length || 0})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveTab('compliance'); setStatusMsg(null); }}
|
||||
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
|
||||
activeTab === 'compliance'
|
||||
? 'border-emerald-600 text-emerald-600 dark:border-emerald-400 dark:text-emerald-400'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
4. Compliance ({meta?.complianceStandards?.length || 0})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
|
||||
className={`px-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'
|
||||
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
Taxonomy Stats ({meta?.totalAssets || 0} Assets)
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
Stats ({meta?.totalAssets || 0})
|
||||
</button>
|
||||
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
Partner Announcements
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
Announcements
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -284,72 +445,303 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Taxonomy Stats */}
|
||||
{activeTab === 'taxonomy' && (
|
||||
{/* TAB 2: Tech Stacks */}
|
||||
{activeTab === 'techStacks' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
|
||||
<div className="text-xs text-slate-500 font-medium">Total Catalog Assets</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-2xl font-bold text-emerald-600">{meta?.verticals.length || 0}</div>
|
||||
<div className="text-xs text-slate-500 font-medium font-mono">Industry Verticals</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-2xl font-bold text-amber-500">{meta?.subcategories.length || 0}</div>
|
||||
<div className="text-xs text-slate-500 font-medium">Subcategories</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-2xl font-bold text-cyan-600">{meta?.tags.length || 0}</div>
|
||||
<div className="text-xs text-slate-500 font-medium">Unique Tags</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3">
|
||||
Subcategory Breakdown (Click to Inspect Records)
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(meta?.subcategories || []).map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
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'
|
||||
}`}
|
||||
{/* Create Tech Stack Form */}
|
||||
<form onSubmit={handleCreateTechStack} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
|
||||
Add New Tech Stack / Capability Item
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Tech Item Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Rust, PyTorch, GraphQL"
|
||||
value={newTechName}
|
||||
onChange={e => setNewTechName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Tech Category / Group
|
||||
</label>
|
||||
<select
|
||||
value={newTechCategory}
|
||||
onChange={e => setNewTechCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="font-extrabold px-1.5 py-0.2 bg-slate-100 dark:bg-slate-700 rounded text-[10px]">
|
||||
{s.count}
|
||||
</span>
|
||||
</button>
|
||||
<option value="Languages & Frameworks">Languages & Frameworks</option>
|
||||
<option value="AI & ML">AI & ML</option>
|
||||
<option value="Data & Backend">Data & Backend</option>
|
||||
<option value="Cloud & Infra">Cloud & Infra</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={newTechColor}
|
||||
onChange={e => setNewTechColor(e.target.value)}
|
||||
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newTechName.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Tech Item
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Tech Stack List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Active Tech Stack Items ({meta?.techStacks?.length || 0})
|
||||
</h3>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
{(meta?.techStacks || []).map(t => (
|
||||
<div key={t.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: t.color || '#64748b' }} />
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
{t.name}
|
||||
<span className="text-[10px] font-extrabold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||
{t.category}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||
{t._count?.assets ?? 0} assets
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteTechStack(t.id, t.name)}
|
||||
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
|
||||
title="Delete Tech Item"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Engagement Types */}
|
||||
{activeTab === 'engagements' && (
|
||||
<div className="space-y-6">
|
||||
{/* Create Engagement Form */}
|
||||
<form onSubmit={handleCreateEngagement} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
|
||||
Add New Engagement Type
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Engagement Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Audit & Advisory, Advisory"
|
||||
value={newEngagementName}
|
||||
onChange={e => setNewEngagementName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Badge Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={newEngagementColor}
|
||||
onChange={e => setNewEngagementColor(e.target.value)}
|
||||
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newEngagementName.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Engagement Type
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Engagements List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Active Engagement Types ({meta?.engagementTypes?.length || 0})
|
||||
</h3>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
{(meta?.engagementTypes || []).map(e => (
|
||||
<div key={e.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: e.color || '#0284c7' }} />
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">{e.name}</span>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||
{e._count?.assets ?? 0} assets
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteEngagement(e.id, e.name)}
|
||||
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
|
||||
title="Delete Engagement Type"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Compliance Standards */}
|
||||
{activeTab === 'compliance' && (
|
||||
<div className="space-y-6">
|
||||
{/* Create Compliance Form */}
|
||||
<form onSubmit={handleCreateCompliance} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
|
||||
Add New Compliance Standard / Regulatory Certification
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Compliance Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. ISO 27001, PCI-DSS, FedRAMP"
|
||||
value={newComplianceName}
|
||||
onChange={e => setNewComplianceName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
|
||||
Badge Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={newComplianceColor}
|
||||
onChange={e => setNewComplianceColor(e.target.value)}
|
||||
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newComplianceName.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Compliance Standard
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Compliance List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Active Compliance Standards ({meta?.complianceStandards?.length || 0})
|
||||
</h3>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
{(meta?.complianceStandards || []).map(c => (
|
||||
<div key={c.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: c.color || '#10b981' }} />
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-emerald-500" />
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
|
||||
{c._count?.assets ?? 0} assets
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteCompliance(c.id, c.name)}
|
||||
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
|
||||
title="Delete Compliance Standard"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Taxonomy Stats */}
|
||||
{activeTab === 'taxonomy' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2.5">
|
||||
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium">Total Assets</div>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-xl font-bold text-blue-600">{meta?.verticals.length || 0}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium">1. Verticals</div>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-xl font-bold text-purple-600">{meta?.techStacks?.length || 0}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium">2. Tech Stacks</div>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-xl font-bold text-sky-600">{meta?.engagementTypes?.length || 0}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium">3. Engagements</div>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-xl font-bold text-emerald-600">{meta?.complianceStandards?.length || 0}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium">4. Compliance</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. Industry Verticals Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-3">
|
||||
Vertical Domain Distribution
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
|
||||
1. Industry Verticals Domain Distribution
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(meta?.verticals || []).map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
|
||||
className={`p-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
|
||||
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
|
||||
<span className="font-bold text-xs">{v.name}</span>
|
||||
</div>
|
||||
<span className="text-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
|
||||
</span>
|
||||
</button>
|
||||
@ -357,6 +749,59 @@ export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Tech Stack Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
|
||||
2. Technology Stack & Capabilities
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(meta?.techStacks || []).map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="px-2.5 py-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
|
||||
<span className="font-semibold">{t.name}</span>
|
||||
<span className="text-[10px] font-mono opacity-70">({t._count?.assets ?? 0})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 & 4. Engagement & Compliance Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
|
||||
3. Engagement Types
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{(meta?.engagementTypes || []).map(e => (
|
||||
<div key={e.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
|
||||
<span>{e.name}</span>
|
||||
<span className="font-mono text-[10px] opacity-70">({e._count?.assets ?? 0} assets)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
|
||||
4. Compliance & Regulatory
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{(meta?.complianceStandards || []).map(c => (
|
||||
<div key={c.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-emerald-500" />
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] opacity-70">({c._count?.assets ?? 0} assets)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inspect Filter Asset List Details */}
|
||||
{inspectFilter && (
|
||||
<div className="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">
|
||||
|
||||
@ -14,7 +14,8 @@ import {
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Globe,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
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="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
|
||||
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"
|
||||
style={{
|
||||
backgroundColor: `${asset.verticals[0].color || '#6366f1'}15`,
|
||||
borderColor: `${asset.verticals[0].color || '#6366f1'}40`,
|
||||
color: asset.verticals[0].color || '#6366f1',
|
||||
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: asset.verticals[0].color || '#6366f1' }} />
|
||||
{asset.verticals[0].name}
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
|
||||
{v.name}
|
||||
</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">
|
||||
{asset.subcategory || asset.categoryId || 'General'}
|
||||
</div>
|
||||
|
||||
@ -50,7 +50,6 @@ export const AssetTableView: React.FC<AssetTableViewProps> = ({
|
||||
{assets.map((asset) => {
|
||||
const isSelected = selectedIds.includes(asset.id);
|
||||
const isRecommended = recommendedIds.includes(asset.id);
|
||||
const primaryVertical = asset.verticals && asset.verticals.length > 0 ? asset.verticals[0] : null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@ -117,23 +116,58 @@ export const AssetTableView: React.FC<AssetTableViewProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Vertical */}
|
||||
{/* Taxonomy Classification */}
|
||||
<td className="py-3 px-4">
|
||||
{primaryVertical ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border"
|
||||
style={{
|
||||
backgroundColor: `${primaryVertical.color}15`,
|
||||
borderColor: `${primaryVertical.color}40`,
|
||||
color: primaryVertical.color || '#94a3b8',
|
||||
}}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: primaryVertical.color || '#94a3b8' }} />
|
||||
{primaryVertical.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400 italic text-[11px]">General</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{asset.verticals && asset.verticals.map(v => (
|
||||
<span
|
||||
key={v.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border"
|
||||
style={{
|
||||
backgroundColor: `${v.color || '#3b82f6'}15`,
|
||||
borderColor: `${v.color || '#3b82f6'}40`,
|
||||
color: v.color || '#3b82f6',
|
||||
}}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
|
||||
{v.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{asset.techStacks && asset.techStacks.map(t => (
|
||||
<span
|
||||
key={t.id}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{asset.engagementTypes && asset.engagementTypes.map(e => (
|
||||
<span
|
||||
key={e.id}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
|
||||
>
|
||||
{e.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{asset.complianceStandards && asset.complianceStandards.map(c => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{(!asset.verticals || asset.verticals.length === 0) &&
|
||||
(!asset.techStacks || asset.techStacks.length === 0) &&
|
||||
(!asset.engagementTypes || asset.engagementTypes.length === 0) &&
|
||||
(!asset.complianceStandards || asset.complianceStandards.length === 0) && (
|
||||
<span className="text-slate-400 italic text-[11px]">General</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { updateAsset, getVerticals } from '../../../services/assets-api';
|
||||
import type { Asset, Vertical } from '../../../types/assets';
|
||||
import { updateAsset, getTaxonomyMeta } from '../../../services/assets-api';
|
||||
import type { Asset, TaxonomyMeta } from '../../../types/assets';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
@ -19,8 +19,13 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
onSuccess
|
||||
}) => {
|
||||
const { success, error } = useToast();
|
||||
const [verticals, setVerticals] = useState<Vertical[]>([]);
|
||||
const [editVerticalId, setEditVerticalId] = useState<string>('');
|
||||
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
|
||||
|
||||
const [editVerticalIds, setEditVerticalIds] = useState<string[]>([]);
|
||||
const [editTechStackIds, setEditTechStackIds] = useState<string[]>([]);
|
||||
const [editEngagementTypeIds, setEditEngagementTypeIds] = useState<string[]>([]);
|
||||
const [editComplianceIds, setEditComplianceIds] = useState<string[]>([]);
|
||||
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editCategory, setEditCategory] = useState('Marketing');
|
||||
@ -31,7 +36,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getVerticals().then(setVerticals).catch(console.error);
|
||||
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,10 +48,18 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
setEditTags(asset.tags.join(', '));
|
||||
setEditGithubUrl(asset.githubUrl || '');
|
||||
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]);
|
||||
|
||||
const toggleSelection = (list: string[], item: string) => {
|
||||
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!asset) return;
|
||||
@ -58,7 +71,10 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
description: editDescription,
|
||||
categoryId: editCategory,
|
||||
subcategory: editSubcategory,
|
||||
verticalIds: editVerticalId ? [editVerticalId] : [],
|
||||
verticalIds: editVerticalIds,
|
||||
techStackIds: editTechStackIds,
|
||||
engagementTypeIds: editEngagementTypeIds,
|
||||
complianceIds: editComplianceIds,
|
||||
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
githubUrl: editGithubUrl,
|
||||
isDownloadable: editIsDownloadable,
|
||||
@ -115,43 +131,130 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||
/>
|
||||
</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>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Industry Vertical</label>
|
||||
<select
|
||||
value={editVerticalId}
|
||||
onChange={(e) => setEditVerticalId(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">General (No Domain)</option>
|
||||
{verticals.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
1. Industry Verticals / Domains
|
||||
</label>
|
||||
<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">
|
||||
{(taxonomyMeta?.verticals || []).map(v => {
|
||||
const isSelected = editVerticalIds.includes(v.id);
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setEditVerticalIds(toggleSelection(editVerticalIds, v.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Tech Stack */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={editCategory}
|
||||
onChange={(e) => setEditCategory(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="Marketing">Marketing</option>
|
||||
<option value="Presentations">Presentations</option>
|
||||
<option value="Branding">Branding</option>
|
||||
<option value="Resources">Resources</option>
|
||||
<option value="Technical">Technical</option>
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
2. Technology Stack & Capabilities
|
||||
</label>
|
||||
<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">
|
||||
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
|
||||
const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={catName} className="space-y-1">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||||
{catName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map(t => {
|
||||
const isSelected = editTechStackIds.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setEditTechStackIds(toggleSelection(editTechStackIds, t.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubcategory}
|
||||
onChange={(e) => setEditSubcategory(e.target.value)}
|
||||
placeholder="e.g. Slide Deck"
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Group 3 & Group 4 Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Group 3: Engagement Type */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
3. Engagement Type
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
{(taxonomyMeta?.engagementTypes || []).map(e => {
|
||||
const isSelected = editEngagementTypeIds.includes(e.id);
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => setEditEngagementTypeIds(toggleSelection(editEngagementTypeIds, e.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{e.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 4: Compliance Standards */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
4. Compliance & Governance
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
{(taxonomyMeta?.complianceStandards || []).map(c => {
|
||||
const isSelected = editComplianceIds.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setEditComplianceIds(toggleSelection(editComplianceIds, c.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -129,10 +129,10 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
|
||||
{/* Body Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
|
||||
|
||||
{/* 1. Industry Verticals */}
|
||||
{/* 1. Industry Verticals (Group 1) */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
|
||||
<span>Industry Verticals</span>
|
||||
<span>1. Industry Verticals</span>
|
||||
<span className="text-[10px] lowercase font-normal text-slate-400">
|
||||
({filteredVerticals.length})
|
||||
</span>
|
||||
@ -189,7 +189,130 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
|
||||
</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>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
|
||||
Content Types
|
||||
@ -237,7 +360,7 @@ export const FilterDrawer: React.FC<FilterDrawerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Subcategories */}
|
||||
{/* 6. Subcategories */}
|
||||
{filteredSubcategories.length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 4. Tag Cloud */}
|
||||
{/* 7. Tag Cloud */}
|
||||
{filteredTags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
||||
import { uploadAsset, scrapeCaseStudy, getVerticals } from '../../../services/assets-api';
|
||||
import type { Vertical } from '../../../types/assets';
|
||||
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta } from '../../../services/assets-api';
|
||||
import type { TaxonomyMeta } from '../../../types/assets';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
@ -18,8 +18,12 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
onSuccess
|
||||
}) => {
|
||||
const { success, error } = useToast();
|
||||
const [verticals, setVerticals] = useState<Vertical[]>([]);
|
||||
const [selectedVerticalId, setSelectedVerticalId] = useState<string>('');
|
||||
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
|
||||
|
||||
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 [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
@ -41,7 +45,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getVerticals().then(setVerticals).catch(console.error);
|
||||
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
|
||||
}, []);
|
||||
|
||||
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('githubUrl', uploadGithubUrl);
|
||||
formData.append('isDownloadable', String(uploadIsDownloadable));
|
||||
if (selectedVerticalId) {
|
||||
formData.append('verticalIds', JSON.stringify([selectedVerticalId]));
|
||||
if (selectedVerticalIds.length) {
|
||||
formData.append('verticalIds', JSON.stringify(selectedVerticalIds));
|
||||
}
|
||||
if (selectedTechStackIds.length) {
|
||||
formData.append('techStackIds', JSON.stringify(selectedTechStackIds));
|
||||
}
|
||||
if (selectedEngagementTypeIds.length) {
|
||||
formData.append('engagementTypeIds', JSON.stringify(selectedEngagementTypeIds));
|
||||
}
|
||||
if (selectedComplianceIds.length) {
|
||||
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
|
||||
}
|
||||
|
||||
if (thumbnailUrl) {
|
||||
@ -184,6 +197,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
setUploadSubcategory('');
|
||||
setUploadTags('');
|
||||
setUploadGithubUrl('');
|
||||
setSelectedVerticalIds([]);
|
||||
setSelectedTechStackIds([]);
|
||||
setSelectedEngagementTypeIds([]);
|
||||
setSelectedComplianceIds([]);
|
||||
setUploadIsDownloadable(true);
|
||||
success('Asset published successfully', 'The asset has been added to the catalog.');
|
||||
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 (
|
||||
<>
|
||||
<Modal
|
||||
@ -466,43 +487,130 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
/>
|
||||
</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>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Industry Vertical / Domain</label>
|
||||
<select
|
||||
value={selectedVerticalId}
|
||||
onChange={(e) => setSelectedVerticalId(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">General (No Domain)</option>
|
||||
{verticals.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
1. Industry Verticals / Domains
|
||||
</label>
|
||||
<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">
|
||||
{(taxonomyMeta?.verticals || []).map(v => {
|
||||
const isSelected = selectedVerticalIds.includes(v.id);
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedVerticalIds(toggleSelection(selectedVerticalIds, v.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Tech Stack */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
||||
<select
|
||||
value={uploadCategory}
|
||||
onChange={(e) => setUploadCategory(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="Marketing">Marketing</option>
|
||||
<option value="Presentations">Presentations</option>
|
||||
<option value="Branding">Branding</option>
|
||||
<option value="Resources">Resources</option>
|
||||
<option value="Technical">Technical</option>
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
2. Technology Stack & Capabilities
|
||||
</label>
|
||||
<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">
|
||||
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
|
||||
const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={catName} className="space-y-1">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||||
{catName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map(t => {
|
||||
const isSelected = selectedTechStackIds.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTechStackIds(toggleSelection(selectedTechStackIds, t.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadSubcategory}
|
||||
onChange={(e) => setUploadSubcategory(e.target.value)}
|
||||
placeholder="e.g. Slide Deck"
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Group 3 & Group 4 Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Group 3: Engagement Type */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
3. Engagement Type
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
{(taxonomyMeta?.engagementTypes || []).map(e => {
|
||||
const isSelected = selectedEngagementTypeIds.includes(e.id);
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedEngagementTypeIds(toggleSelection(selectedEngagementTypeIds, e.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{e.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 4: Compliance Standards */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
|
||||
4. Compliance & Governance
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
{(taxonomyMeta?.complianceStandards || []).map(c => {
|
||||
const isSelected = selectedComplianceIds.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedComplianceIds(toggleSelection(selectedComplianceIds, c.id))}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
|
||||
isSelected
|
||||
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
|
||||
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -314,7 +314,7 @@ export const AssetsPage = () => {
|
||||
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-wrap items-center gap-3 flex-1 min-w-0">
|
||||
|
||||
|
||||
{/* Search Bar */}
|
||||
<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" />
|
||||
@ -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">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${
|
||||
viewMode === 'grid'
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'grid'
|
||||
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
}`}
|
||||
title="Grid Cards"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${
|
||||
viewMode === 'compact'
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'compact'
|
||||
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
}`}
|
||||
title="Compact Cards"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${
|
||||
viewMode === 'table'
|
||||
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'table'
|
||||
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
}`}
|
||||
title="Tabular List View (Shift+Click)"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
@ -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">
|
||||
<button
|
||||
onClick={() => setSelectedCategory("ALL")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
selectedCategory === "ALL"
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL"
|
||||
? "bg-slate-900 text-white shadow-xs"
|
||||
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
All Assets
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("RECOMMENDED")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${
|
||||
selectedCategory === "RECOMMENDED"
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${selectedCategory === "RECOMMENDED"
|
||||
? "bg-amber-500 text-slate-950 font-bold shadow-xs"
|
||||
: "text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Recommended ({recommendedAssets.length})</span>
|
||||
@ -498,11 +493,10 @@ export const AssetsPage = () => {
|
||||
{/* Filter Drawer Trigger Button positioned on the far right end */}
|
||||
<button
|
||||
onClick={() => setIsFilterDrawerOpen(true)}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${
|
||||
activeFilterCount > 0
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${activeFilterCount > 0
|
||||
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900"
|
||||
: "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filters</span>
|
||||
@ -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>
|
||||
{selectedAssetIds.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-bold border border-slate-300 dark:border-slate-700">
|
||||
{selectedAssetIds.length} selected (Hold Shift to range-select)
|
||||
{selectedAssetIds.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -583,11 +577,10 @@ export const AssetsPage = () => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className={`grid grid-cols-1 ${
|
||||
viewMode === 'compact'
|
||||
className={`grid grid-cols-1 ${viewMode === 'compact'
|
||||
? 'md:grid-cols-3 xl:grid-cols-5 gap-3'
|
||||
: 'md:grid-cols-2 xl:grid-cols-4 gap-4'
|
||||
} items-start`}
|
||||
} items-start`}
|
||||
>
|
||||
{filteredAssets.map((asset) => (
|
||||
<motion.div
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
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[]> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters?.search) params.search = filters.search;
|
||||
if (filters?.verticalIds?.length) params.verticalIds = filters.verticalIds.join(',');
|
||||
if (filters?.techStackIds?.length) params.techStackIds = filters.techStackIds.join(',');
|
||||
if (filters?.engagementTypeIds?.length) params.engagementTypeIds = filters.engagementTypeIds.join(',');
|
||||
if (filters?.complianceIds?.length) params.complianceIds = filters.complianceIds.join(',');
|
||||
if (filters?.contentTypes?.length) params.contentTypes = filters.contentTypes.join(',');
|
||||
if (filters?.subcategories?.length) params.subcategories = filters.subcategories.join(',');
|
||||
if (filters?.tags?.length) params.tags = filters.tags.join(',');
|
||||
@ -33,6 +36,9 @@ export const updateAsset = async (
|
||||
categoryId?: string;
|
||||
subcategory?: string;
|
||||
verticalIds?: string[];
|
||||
techStackIds?: string[];
|
||||
engagementTypeIds?: string[];
|
||||
complianceIds?: string[];
|
||||
tags?: string[];
|
||||
githubUrl?: string;
|
||||
isDownloadable?: boolean;
|
||||
@ -139,6 +145,33 @@ export const deleteVertical = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/taxonomy/verticals/${id}`);
|
||||
};
|
||||
|
||||
export const createTechStack = async (payload: { name: string; category?: string; icon?: string; description?: string; color?: string }): Promise<TechStack> => {
|
||||
const response = await axiosInstance.post<TechStack>('/taxonomy/tech-stacks', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteTechStack = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/taxonomy/tech-stacks/${id}`);
|
||||
};
|
||||
|
||||
export const createEngagementType = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<EngagementType> => {
|
||||
const response = await axiosInstance.post<EngagementType>('/taxonomy/engagement-types', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteEngagementType = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/taxonomy/engagement-types/${id}`);
|
||||
};
|
||||
|
||||
export const createComplianceStandard = async (payload: { name: string; icon?: string; description?: string; color?: string }): Promise<ComplianceStandard> => {
|
||||
const response = await axiosInstance.post<ComplianceStandard>('/taxonomy/compliance-standards', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteComplianceStandard = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/taxonomy/compliance-standards/${id}`);
|
||||
};
|
||||
|
||||
// Notification API
|
||||
export const sendAssetAnnouncement = async (payload: { title: string; message: string; targetOrgIds?: string[]; assetIds?: string[] }): Promise<void> => {
|
||||
await axiosInstance.post('/assets/notify', payload);
|
||||
|
||||
@ -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 {
|
||||
verticals: Vertical[];
|
||||
techStacks: TechStack[];
|
||||
engagementTypes: EngagementType[];
|
||||
complianceStandards: ComplianceStandard[];
|
||||
categories: { name: string; count: number }[];
|
||||
subcategories: { name: string; count: number }[];
|
||||
contentTypes: { name: string; count: number }[];
|
||||
@ -52,6 +98,9 @@ export interface TaxonomyMeta {
|
||||
export interface AssetQueryFilters {
|
||||
search?: string;
|
||||
verticalIds?: string[];
|
||||
techStackIds?: string[];
|
||||
engagementTypeIds?: string[];
|
||||
complianceIds?: string[];
|
||||
contentTypes?: string[];
|
||||
subcategories?: string[];
|
||||
tags?: string[];
|
||||
@ -80,6 +129,9 @@ export interface Asset {
|
||||
solution?: string | null;
|
||||
createdAt: string;
|
||||
verticals?: Vertical[];
|
||||
techStacks?: TechStack[];
|
||||
engagementTypes?: EngagementType[];
|
||||
complianceStandards?: ComplianceStandard[];
|
||||
sharedWith?: SharedWithOrg[];
|
||||
downloadRequests?: DownloadRequest[];
|
||||
}
|
||||
|
||||
2116
player_response.json
2116
player_response.json
File diff suppressed because it is too large
Load Diff
83
youtube.html
83
youtube.html
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
1132
ytInitialData.json
1132
ytInitialData.json
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user