From 97439eda858c6da594e631325798d365b9978da3 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 11 May 2026 12:32:55 +0000 Subject: [PATCH 1/3] 2 --- backend/src/routes/ai_use_cases.js | 9 +- backend/src/routes/approval_steps.js | 5 + backend/src/services/ai_use_cases.js | 159 +- backend/src/services/approval_steps.js | 151 +- frontend/src/colors.ts | 20 +- .../src/components/Ai_tools/TableAi_tools.tsx | 56 +- .../Ai_use_cases/TableAi_use_cases.tsx | 74 +- .../configureAi_use_casesCols.tsx | 27 +- .../Approval_steps/TableApproval_steps.tsx | 56 +- frontend/src/components/AsideMenu.tsx | 2 +- frontend/src/components/AsideMenuItem.tsx | 41 +- frontend/src/components/AsideMenuLayer.tsx | 18 +- frontend/src/components/AsideMenuList.tsx | 2 +- frontend/src/components/CardBox.tsx | 4 +- .../Checklist_items/ListChecklist_items.tsx | 171 +- .../Checklist_items/TableChecklist_items.tsx | 56 +- frontend/src/components/DevModeBadge.tsx | 149 +- frontend/src/components/FooterBar.tsx | 25 +- .../Integrations/CardIntegrations.tsx | 246 +-- .../components/KanbanBoard/KanbanBoard.tsx | 2 +- .../src/components/KanbanBoard/KanbanCard.tsx | 31 +- .../components/KanbanBoard/KanbanColumn.tsx | 22 +- frontend/src/components/LegalOpsPageIntro.tsx | 48 + frontend/src/components/NavBar.tsx | 8 +- frontend/src/components/NavBarItem.tsx | 9 +- frontend/src/components/NavBarItemPlain.tsx | 2 +- .../Practice_groups/CardPractice_groups.tsx | 179 +- .../Practice_groups/TablePractice_groups.tsx | 56 +- frontend/src/components/Search.tsx | 7 +- frontend/src/components/SectionMain.tsx | 2 +- .../components/SectionTitleLineWithButton.tsx | 21 +- .../Training_courses/CardTraining_courses.tsx | 295 ++-- .../TableTraining_courses.tsx | 56 +- .../TableVendor_risk_assessments.tsx | 56 +- .../src/components/Vendors/ListVendors.tsx | 276 +-- .../src/components/Vendors/TableVendors.tsx | 56 +- frontend/src/config.ts | 2 +- frontend/src/helpers/legalAiFormatting.ts | 171 ++ frontend/src/interfaces/index.ts | 1 + frontend/src/layouts/Authenticated.tsx | 62 +- frontend/src/menuAside.ts | 384 +++-- frontend/src/pages/ai_tools/ai_tools-list.tsx | 37 +- .../pages/ai_use_cases/ai_use_cases-list.tsx | 52 +- .../pages/ai_use_cases/ai_use_cases-new.tsx | 1053 ++++-------- .../pages/ai_use_cases/ai_use_cases-view.tsx | 1493 ++++++----------- .../approval_steps/approval_steps-list.tsx | 41 +- .../checklist_items/checklist_items-list.tsx | 35 +- .../checklist_items/checklist_items-table.tsx | 36 +- frontend/src/pages/governance-workbench.tsx | 606 +++++++ frontend/src/pages/index.tsx | 643 +++++-- .../pages/integrations/integrations-list.tsx | 15 +- frontend/src/pages/login.tsx | 491 +++--- .../practice_groups/practice_groups-list.tsx | 38 +- .../practice_groups/practice_groups-table.tsx | 40 +- .../training_courses-list.tsx | 40 +- .../training_courses-table.tsx | 42 +- .../vendor_risk_assessments-list.tsx | 41 +- frontend/src/pages/vendors/vendors-list.tsx | 39 +- frontend/src/styles.ts | 55 +- 59 files changed, 4153 insertions(+), 3661 deletions(-) create mode 100644 frontend/src/components/LegalOpsPageIntro.tsx create mode 100644 frontend/src/helpers/legalAiFormatting.ts create mode 100644 frontend/src/pages/governance-workbench.tsx diff --git a/backend/src/routes/ai_use_cases.js b/backend/src/routes/ai_use_cases.js index 4316772..b1a72b4 100644 --- a/backend/src/routes/ai_use_cases.js +++ b/backend/src/routes/ai_use_cases.js @@ -91,8 +91,8 @@ router.use(checkCrudPermissions('ai_use_cases')); router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await Ai_use_casesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; + const createdAiUseCase = await Ai_use_casesService.create(req.body.data, req.currentUser, true, link.host); + const payload = createdAiUseCase?.get ? createdAiUseCase.get({ plain: true }) : createdAiUseCase; res.status(200).send(payload); })); @@ -193,6 +193,11 @@ router.put('/:id', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.put('/:id/submit', wrapAsync(async (req, res) => { + const payload = await Ai_use_casesService.submitForReview(req.params.id, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/ai_use_cases/{id}: diff --git a/backend/src/routes/approval_steps.js b/backend/src/routes/approval_steps.js index 1367780..1d2c6ee 100644 --- a/backend/src/routes/approval_steps.js +++ b/backend/src/routes/approval_steps.js @@ -184,6 +184,11 @@ router.put('/:id', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.put('/:id/decision', wrapAsync(async (req, res) => { + const payload = await Approval_stepsService.recordDecision(req.params.id, req.body, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/approval_steps/{id}: diff --git a/backend/src/services/ai_use_cases.js b/backend/src/services/ai_use_cases.js index ab68f55..d13b474 100644 --- a/backend/src/services/ai_use_cases.js +++ b/backend/src/services/ai_use_cases.js @@ -1,21 +1,69 @@ const db = require('../db/models'); const Ai_use_casesDBApi = require('../db/api/ai_use_cases'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const APPROVAL_SEQUENCE = [ + { + step_type: 'partner', + step_order: 1, + reviewerRoles: ['Governance Lead', 'Administrator'], + }, + { + step_type: 'general_counsel', + step_order: 2, + reviewerRoles: ['General Counsel Reviewer', 'Administrator'], + }, + { + step_type: 'it_security', + step_order: 3, + reviewerRoles: ['IT Security Reviewer', 'Administrator'], + }, + { + step_type: 'ethics_risk', + step_order: 4, + reviewerRoles: ['Ethics and Risk Reviewer', 'Administrator'], + }, +]; +function buildHttpError(message, code = 400) { + const error = new Error(message); + error.code = code; + return error; +} +async function findReviewerId(reviewerRoles, transaction) { + for (const roleName of reviewerRoles) { + const reviewer = await db.users.findOne({ + include: [ + { + model: db.roles, + as: 'app_role', + required: true, + where: { name: roleName }, + }, + ], + order: [['createdAt', 'ASC']], + transaction, + }); + + if (reviewer?.id) { + return reviewer.id; + } + } + + return null; +} module.exports = class Ai_use_casesService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); + try { - await Ai_use_casesDBApi.create( + const createdAiUseCase = await Ai_use_casesDBApi.create( data, { currentUser, @@ -24,13 +72,14 @@ module.exports = class Ai_use_casesService { ); await transaction.commit(); + return createdAiUseCase; } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +87,7 @@ module.exports = class Ai_use_casesService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +98,13 @@ module.exports = class Ai_use_casesService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Ai_use_casesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -67,16 +116,15 @@ module.exports = class Ai_use_casesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); + try { - let ai_use_cases = await Ai_use_casesDBApi.findBy( - {id}, - {transaction}, + const ai_use_cases = await Ai_use_casesDBApi.findBy( + { id }, + { transaction }, ); if (!ai_use_cases) { - throw new ValidationError( - 'ai_use_casesNotFound', - ); + throw new ValidationError('ai_use_casesNotFound'); } const updatedAi_use_cases = await Ai_use_casesDBApi.update( @@ -90,12 +138,79 @@ module.exports = class Ai_use_casesService { await transaction.commit(); return updatedAi_use_cases; - } catch (error) { await transaction.rollback(); throw error; } - }; + } + + static async submitForReview(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const aiUseCase = await db.ai_use_cases.findByPk(id, { transaction }); + + if (!aiUseCase) { + throw buildHttpError('AI use case not found.', 404); + } + + const currentStatus = aiUseCase.status || 'draft'; + + if (currentStatus !== 'draft') { + throw buildHttpError('Only draft AI use cases can be submitted for review.'); + } + + if (!aiUseCase.title || !aiUseCase.business_goal || !aiUseCase.data_classificationId || !aiUseCase.intended_toolId) { + throw buildHttpError( + 'Add a title, business goal, data classification, and intended AI tool before submitting for review.', + ); + } + + const existingApprovalSteps = await db.approval_steps.count({ + where: { use_caseId: id }, + transaction, + }); + + if (existingApprovalSteps > 0) { + throw buildHttpError('This AI use case already has an approval workflow in progress.'); + } + + for (const approvalStep of APPROVAL_SEQUENCE) { + const assignedReviewerId = await findReviewerId(approvalStep.reviewerRoles, transaction); + + await db.approval_steps.create( + { + step_type: approvalStep.step_type, + decision: 'pending', + comments: null, + assigned_at: new Date(), + decided_at: null, + step_order: approvalStep.step_order, + use_caseId: aiUseCase.id, + assigned_reviewerId: assignedReviewerId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + await aiUseCase.update( + { + status: 'submitted', + submitted_at: aiUseCase.submitted_at || new Date(), + approved_at: null, + }, + { transaction }, + ); + + await transaction.commit(); + return Ai_use_casesDBApi.findBy({ id }); + } catch (error) { + await transaction.rollback(); + throw error; + } + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -131,8 +246,4 @@ module.exports = class Ai_use_casesService { throw error; } } - - }; - - diff --git a/backend/src/services/approval_steps.js b/backend/src/services/approval_steps.js index 62c72aa..9db1c32 100644 --- a/backend/src/services/approval_steps.js +++ b/backend/src/services/approval_steps.js @@ -1,21 +1,29 @@ const db = require('../db/models'); const Approval_stepsDBApi = require('../db/api/approval_steps'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const NEXT_STATUS_BY_STEP = { + partner: 'risk_review', + general_counsel: 'security_review', + it_security: 'ethics_review', + ethics_risk: 'approved', +}; - - +function buildHttpError(message, code = 400) { + const error = new Error(message); + error.code = code; + return error; +} module.exports = class Approval_stepsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); + try { - await Approval_stepsDBApi.create( + const createdApprovalStep = await Approval_stepsDBApi.create( data, { currentUser, @@ -24,13 +32,14 @@ module.exports = class Approval_stepsService { ); await transaction.commit(); + return createdApprovalStep; } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +47,7 @@ module.exports = class Approval_stepsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +58,13 @@ module.exports = class Approval_stepsService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await Approval_stepsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -67,16 +76,15 @@ module.exports = class Approval_stepsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); + try { - let approval_steps = await Approval_stepsDBApi.findBy( - {id}, - {transaction}, + const approval_steps = await Approval_stepsDBApi.findBy( + { id }, + { transaction }, ); if (!approval_steps) { - throw new ValidationError( - 'approval_stepsNotFound', - ); + throw new ValidationError('approval_stepsNotFound'); } const updatedApproval_steps = await Approval_stepsDBApi.update( @@ -90,12 +98,107 @@ module.exports = class Approval_stepsService { await transaction.commit(); return updatedApproval_steps; - } catch (error) { await transaction.rollback(); throw error; } - }; + } + + static async recordDecision(id, data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const approvalStep = await db.approval_steps.findByPk(id, { transaction }); + + if (!approvalStep) { + throw buildHttpError('Approval step not found.', 404); + } + + if (approvalStep.decision !== 'pending') { + throw buildHttpError('This approval step has already been decided.'); + } + + if (!['approved', 'rejected', 'needs_changes'].includes(data?.decision)) { + throw buildHttpError('Select a valid decision: approved, rejected, or needs_changes.'); + } + + const aiUseCase = await db.ai_use_cases.findByPk(approvalStep.use_caseId, { transaction }); + + if (!aiUseCase) { + throw buildHttpError('The related AI use case could not be found.', 404); + } + + const activePendingStep = await db.approval_steps.findOne({ + where: { + use_caseId: approvalStep.use_caseId, + decision: 'pending', + }, + order: [['step_order', 'ASC']], + transaction, + }); + + if (!activePendingStep || activePendingStep.id !== approvalStep.id) { + throw buildHttpError('Only the current approval step can be decided.'); + } + + await approvalStep.update( + { + decision: data.decision, + comments: data?.comments?.trim() || null, + decided_at: new Date(), + updatedById: currentUser.id, + }, + { transaction }, + ); + + if (data.decision === 'approved') { + const nextStatus = NEXT_STATUS_BY_STEP[approvalStep.step_type] || 'approved'; + + await aiUseCase.update( + nextStatus === 'approved' + ? { + status: 'approved', + approved_at: new Date(), + updatedById: currentUser.id, + } + : { + status: nextStatus, + approved_at: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + if (data.decision === 'rejected') { + await aiUseCase.update( + { + status: 'rejected', + approved_at: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + if (data.decision === 'needs_changes') { + await aiUseCase.update( + { + status: 'needs_changes', + approved_at: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + await transaction.commit(); + return Approval_stepsDBApi.findBy({ id }); + } catch (error) { + await transaction.rollback(); + throw error; + } + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -131,8 +234,4 @@ module.exports = class Approval_stepsService { throw error; } } - - }; - - diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts index 71e116a..d160d35 100644 --- a/frontend/src/colors.ts +++ b/frontend/src/colors.ts @@ -14,7 +14,7 @@ export const colorsBgLight = { success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', danger: 'bg-red-500 border-red-500 text-white', warning: 'bg-yellow-500 border-yellow-500 text-white', - info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', + info: 'bg-[#0E1A2B] border-[#0E1A2B] dark:bg-pavitra-blue dark:border-pavitra-blue text-white', } export const colorsText = { @@ -24,7 +24,7 @@ export const colorsText = { success: 'text-emerald-500', danger: 'text-red-500', warning: 'text-yellow-500', - info: 'text-blue-500', + info: 'text-[#0E1A2B]', }; export const colorsOutline = { @@ -34,7 +34,7 @@ export const colorsOutline = { success: [colorsText.success, 'border-emerald-500'].join(' '), danger: [colorsText.danger, 'border-red-500'].join(' '), warning: [colorsText.warning, 'border-yellow-500'].join(' '), - info: [colorsText.info, 'border-blue-500'].join(' '), + info: [colorsText.info, 'border-[#0E1A2B]'].join(' '), }; export const getButtonColor = ( @@ -56,7 +56,7 @@ export const getButtonColor = ( success: 'ring-emerald-300 dark:ring-pavitra-blue', danger: 'ring-red-300 dark:ring-red-700', warning: 'ring-yellow-300 dark:ring-yellow-700', - info: "ring-blue-300 dark:ring-pavitra-blue", + info: "ring-[#D8B75E]/35 dark:ring-pavitra-blue", }, active: { white: 'bg-gray-100', @@ -66,7 +66,7 @@ export const getButtonColor = ( success: 'bg-emerald-700 dark:bg-pavitra-blue', danger: 'bg-red-700 dark:bg-red-600', warning: 'bg-yellow-700 dark:bg-yellow-600', - info: 'bg-blue-700 dark:bg-pavitra-blue', + info: 'bg-[#162742] dark:bg-pavitra-blue', }, bg: { white: 'bg-white text-black', @@ -76,7 +76,7 @@ export const getButtonColor = ( success: 'bg-emerald-600 dark:bg-pavitra-blue text-white', danger: 'bg-red-600 text-white dark:bg-red-500 ', warning: 'bg-yellow-600 dark:bg-yellow-500 text-white', - info: " bg-blue-600 dark:bg-pavitra-blue text-white ", + info: " bg-[#0E1A2B] dark:bg-pavitra-blue text-white ", }, bgHover: { white: 'hover:bg-gray-100', @@ -89,7 +89,7 @@ export const getButtonColor = ( 'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600', warning: 'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600', - info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80", + info: "hover:bg-[#162742] hover:border-[#162742] hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80", }, borders: { white: 'border-white', @@ -99,14 +99,14 @@ export const getButtonColor = ( success: 'border-emerald-600 dark:border-pavitra-blue', danger: 'border-red-600 dark:border-red-500', warning: 'border-yellow-600 dark:border-yellow-500', - info: "border-blue-600 border-blue-600 dark:border-pavitra-blue", + info: "border-[#0E1A2B] dark:border-pavitra-blue", }, text: { contrast: 'dark:text-slate-100', success: 'text-emerald-600 dark:text-pavitra-blue', danger: 'text-red-600 dark:text-red-500', warning: 'text-yellow-600 dark:text-yellow-500', - info: 'text-blue-600 dark:text-pavitra-blue', + info: 'text-[#0E1A2B] dark:text-pavitra-blue', }, outlineHover: { contrast: @@ -116,7 +116,7 @@ export const getButtonColor = ( 'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600', warning: 'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600', - info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue", + info: "hover:bg-[#0E1A2B] hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue", }, } diff --git a/frontend/src/components/Ai_tools/TableAi_tools.tsx b/frontend/src/components/Ai_tools/TableAi_tools.tsx index 1564ca3..b7e1d38 100644 --- a/frontend/src/components/Ai_tools/TableAi_tools.tsx +++ b/frontend/src/components/Ai_tools/TableAi_tools.tsx @@ -42,9 +42,6 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid }) const { ai_tools, loading, count, notify: ai_toolsNotify, refetch } = useAppSelector((state) => state.ai_tools) const { currentUser } = useAppSelector((state) => state.auth); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - const corners = useAppSelector((state) => state.style.corners); const numPages = Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); for (let i = 0; i < numPages; i++) { pagesList.push(i); @@ -202,9 +199,7 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid }) }; const controlClasses = - 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + - ` ${bgColor} ${focusRing} ${corners} ` + - 'dark:bg-slate-800 border'; + 'w-full my-1.5 rounded-lg border border-[#D8D0C2] bg-white px-3 py-2 text-sm text-[#0E1A2B] shadow-sm outline-none placeholder:text-[#9A8F7F] focus:border-[#D8B75E] focus:ring-2 focus:ring-[#D8B75E]/25 dark:border-dark-700 dark:bg-slate-800 dark:placeholder-gray-400'; const dataGrid = ( @@ -262,7 +257,7 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid }) return ( <> {filterItems && Array.isArray( filterItems ) && filterItems.length ? - +
<> +
+
+

Active filters

+

Narrow the register without leaving the governance workspace.

+
+ Review scope +
{filterItems && filterItems.map((filterItem) => { return ( -
-
-
Filter
+
+
+
Filter
filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
-
+
+
Value
filter.title === filterItem?.fields?.selectedField )?.number ? ( -
-
-
From
+
+
+
From
-
To
+
To
-
-
+
+
+
From
-
To
+
To
) : ( -
-
Contains
+
+
Contains
)}
-
Action
+
Action
) })} -
+
diff --git a/frontend/src/components/Ai_use_cases/TableAi_use_cases.tsx b/frontend/src/components/Ai_use_cases/TableAi_use_cases.tsx index fef56b0..81171d7 100644 --- a/frontend/src/components/Ai_use_cases/TableAi_use_cases.tsx +++ b/frontend/src/components/Ai_use_cases/TableAi_use_cases.tsx @@ -48,9 +48,6 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri const { ai_use_cases, loading, count, notify: ai_use_casesNotify, refetch } = useAppSelector((state) => state.ai_use_cases) const { currentUser } = useAppSelector((state) => state.auth); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - const corners = useAppSelector((state) => state.style.corners); const numPages = Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); for (let i = 0; i < numPages; i++) { pagesList.push(i); @@ -98,23 +95,23 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri setKanbanColumns([ - { id: "draft", label: "draft" }, + { id: "draft", label: "Draft" }, - { id: "submitted", label: "submitted" }, + { id: "submitted", label: "Submitted" }, - { id: "risk_review", label: "risk_review" }, + { id: "risk_review", label: "Risk review" }, - { id: "security_review", label: "security_review" }, + { id: "security_review", label: "Security review" }, - { id: "ethics_review", label: "ethics_review" }, + { id: "ethics_review", label: "Ethics review" }, - { id: "approved", label: "approved" }, + { id: "approved", label: "Approved" }, - { id: "rejected", label: "rejected" }, + { id: "rejected", label: "Rejected" }, - { id: "needs_changes", label: "needs_changes" }, + { id: "needs_changes", label: "Needs changes" }, - { id: "retired", label: "retired" }, + { id: "retired", label: "Retired" }, ]); @@ -243,9 +240,7 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri }; const controlClasses = - 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + - ` ${bgColor} ${focusRing} ${corners} ` + - 'dark:bg-slate-800 border'; + 'w-full my-1.5 rounded-lg border border-[#D8D0C2] bg-white px-3 py-2 text-sm text-[#0E1A2B] shadow-sm outline-none placeholder:text-[#9A8F7F] focus:border-[#D8B75E] focus:ring-2 focus:ring-[#D8B75E]/25 dark:border-dark-700 dark:bg-slate-800 dark:placeholder-gray-400'; const dataGrid = ( @@ -303,7 +298,7 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri return ( <> {filterItems && Array.isArray( filterItems ) && filterItems.length ? - + <> +
+
+

Active filters

+

Narrow the register without leaving the governance workspace.

+
+ Review scope +
{filterItems && filterItems.map((filterItem) => { return ( -
-
-
Filter
+
+
+
Filter
filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
-
+
+
Value
filter.title === filterItem?.fields?.selectedField )?.number ? ( -
-
-
From
+
+
+
From
-
To
+
To
-
-
+
+
+
From
-
To
+
To
) : ( -
-
Contains
+
+
Contains
)}
-
Action
+
Action
) })} -
+
diff --git a/frontend/src/components/Ai_use_cases/configureAi_use_casesCols.tsx b/frontend/src/components/Ai_use_cases/configureAi_use_casesCols.tsx index 537bbc8..37527fd 100644 --- a/frontend/src/components/Ai_use_cases/configureAi_use_casesCols.tsx +++ b/frontend/src/components/Ai_use_cases/configureAi_use_casesCols.tsx @@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect"; import ListActionsPopover from '../ListActionsPopover'; import {hasPermission} from "../../helpers/userPermissions"; +import { humanize } from '../../helpers/humanize'; type Params = (id: string) => void; @@ -43,7 +44,7 @@ export const loadColumns = async ( { field: 'title', - headerName: 'UseCaseTitle', + headerName: 'Title', flex: 1, minWidth: 120, filterable: false, @@ -95,7 +96,7 @@ export const loadColumns = async ( { field: 'practice_group', - headerName: 'PracticeGroup', + headerName: 'Practice group', flex: 1, minWidth: 120, filterable: false, @@ -117,7 +118,7 @@ export const loadColumns = async ( { field: 'matter_type', - headerName: 'MatterType', + headerName: 'Matter type', flex: 1, minWidth: 120, filterable: false, @@ -139,7 +140,7 @@ export const loadColumns = async ( { field: 'data_classification', - headerName: 'DataClassification', + headerName: 'Data classification', flex: 1, minWidth: 120, filterable: false, @@ -161,7 +162,7 @@ export const loadColumns = async ( { field: 'intended_tool', - headerName: 'IntendedAITool', + headerName: 'Intended AI tool', flex: 1, minWidth: 120, filterable: false, @@ -183,7 +184,7 @@ export const loadColumns = async ( { field: 'business_goal', - headerName: 'BusinessGoal', + headerName: 'Business goal', flex: 1, minWidth: 120, filterable: false, @@ -207,13 +208,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, - + valueFormatter: ({ value }) => value ? humanize(value) : 'Not set', }, { field: 'risk_level', - headerName: 'RiskLevel', + headerName: 'Risk level', flex: 1, minWidth: 120, filterable: false, @@ -222,13 +223,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, - + valueFormatter: ({ value }) => value ? humanize(value) : 'Not set', }, { field: 'expected_hours_saved', - headerName: 'ExpectedHoursSaved', + headerName: 'Expected hours saved', flex: 1, minWidth: 120, filterable: false, @@ -244,7 +245,7 @@ export const loadColumns = async ( { field: 'review_notes', - headerName: 'ReviewNotes', + headerName: 'Review notes', flex: 1, minWidth: 120, filterable: false, @@ -259,7 +260,7 @@ export const loadColumns = async ( { field: 'submitted_at', - headerName: 'SubmittedAt', + headerName: 'Submitted at', flex: 1, minWidth: 120, filterable: false, @@ -277,7 +278,7 @@ export const loadColumns = async ( { field: 'approved_at', - headerName: 'ApprovedAt', + headerName: 'Approved at', flex: 1, minWidth: 120, filterable: false, diff --git a/frontend/src/components/Approval_steps/TableApproval_steps.tsx b/frontend/src/components/Approval_steps/TableApproval_steps.tsx index 09793df..2eae8e1 100644 --- a/frontend/src/components/Approval_steps/TableApproval_steps.tsx +++ b/frontend/src/components/Approval_steps/TableApproval_steps.tsx @@ -42,9 +42,6 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG const { approval_steps, loading, count, notify: approval_stepsNotify, refetch } = useAppSelector((state) => state.approval_steps) const { currentUser } = useAppSelector((state) => state.auth); - const focusRing = useAppSelector((state) => state.style.focusRingColor); - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - const corners = useAppSelector((state) => state.style.corners); const numPages = Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); for (let i = 0; i < numPages; i++) { pagesList.push(i); @@ -202,9 +199,7 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG }; const controlClasses = - 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + - ` ${bgColor} ${focusRing} ${corners} ` + - 'dark:bg-slate-800 border'; + 'w-full my-1.5 rounded-lg border border-[#D8D0C2] bg-white px-3 py-2 text-sm text-[#0E1A2B] shadow-sm outline-none placeholder:text-[#9A8F7F] focus:border-[#D8B75E] focus:ring-2 focus:ring-[#D8B75E]/25 dark:border-dark-700 dark:bg-slate-800 dark:placeholder-gray-400'; const dataGrid = ( @@ -262,7 +257,7 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG return ( <> {filterItems && Array.isArray( filterItems ) && filterItems.length ? - + <> +
+
+

Active filters

+

Narrow the register without leaving the governance workspace.

+
+ Review scope +
{filterItems && filterItems.map((filterItem) => { return ( -
-
-
Filter
+
+
+
Filter
filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
-
+
+
Value
filter.title === filterItem?.fields?.selectedField )?.number ? ( -
-
-
From
+
+
+
From
-
To
+
To
-
-
+
+
+
From
-
To
+
To
) : ( -
-
Contains
+
+
Contains
)}
-
Action
+
Action
) })} -
+
diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx index 442dfac..d6e9595 100644 --- a/frontend/src/components/AsideMenu.tsx +++ b/frontend/src/components/AsideMenu.tsx @@ -19,7 +19,7 @@ export default function AsideMenu({ <> { const [isLinkActive, setIsLinkActive] = useState(false) - const [isDropdownActive, setIsDropdownActive] = useState(false) + const [isDropdownActive, setIsDropdownActive] = useState(Boolean(item.isOpenByDefault)) const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle) - const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle) const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle) const borders = useAppSelector((state) => state.style.borders); const activeLinkColor = useAppSelector( @@ -38,17 +37,33 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { setIsLinkActive(linkPathNameView === activeView); } + + if (item.menu && isReady) { + const activePathname = new URL(asPath, location.href).pathname + const activeView = activePathname.split('/')[1]; + const hasActiveChild = item.menu.some((menuItem) => { + if (!menuItem.href) { + return false; + } + + const childPathName = new URL(menuItem.href, location.href).pathname + '/'; + const childView = childPathName.split('/')[1]; + return childView === activeView; + }); + + if (hasActiveChild) { + setIsDropdownActive(true); + } + } }, [item.href, isReady, asPath]) const asideMenuItemInnerContents = ( <> {item.icon && ( - + )} {item.label} @@ -56,25 +71,25 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { )} ) const componentClass = [ - 'flex cursor-pointer py-1.5 ', - isDropdownList ? 'px-6 text-sm' : '', + 'flex min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition-colors', + isDropdownList ? 'text-sm' : 'text-[15px] font-medium', item.color ? getButtonColor(item.color, false, true) : `${asideMenuItemStyle}`, isLinkActive - ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` + ? `text-[#0E1A2B] ${activeLinkColor} dark:text-white dark:bg-dark-800` : '', ].join(' '); return ( -
  • +
  • {item.withDevider &&
    } {item.href && ( @@ -89,8 +104,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { {item.menu && ( diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index c78bf73..0d7c11d 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -29,19 +29,21 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props return (
  • - ))} - {!loading && integrations.length === 0 && ( -
    -

    No data to display

    -
    - )} +
    + + ); + })} -
    + + {!loading && integrations.length === 0 && ( +
    +

    No integrations to display

    +
    + )} + +
    diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx index 7655572..6474260 100644 --- a/frontend/src/components/KanbanBoard/KanbanCard.tsx +++ b/frontend/src/components/KanbanBoard/KanbanCard.tsx @@ -12,6 +12,8 @@ type Props = { setItemIdToDelete: (id: string) => void; }; +const formatValue = (value: string) => value.replace(/_/g, ' '); + const KanbanCard = ({ item, entityName, @@ -30,23 +32,46 @@ const KanbanCard = ({ [item], ); + const statusValue = + item.risk_level || + item.approval_status || + item.assessment_status || + item.vendor_status || + item.decision; + const summary = + item.business_goal || + item.security_posture_summary || + item.findings || + item.comments || + item.notes; + return (
    {item[showFieldName] ?? 'No data'} + {statusValue && ( + + {formatValue(statusValue)} + + )}
    + {summary && ( +

    + {summary} +

    + )}
    -

    {moment(item.createdAt).format('MMM DD hh:mm a')}

    +

    {moment(item.createdAt).format('MMM DD hh:mm a')}

    { + const displayLabel = column.label + .replace(/_/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); const [currentPage, setCurrentPage] = useState(0); const [count, setCount] = useState(0); const [data, setData] = useState(null); @@ -161,19 +164,24 @@ const KanbanColumn = ({ -
    -

    {column.label}

    -

    {count}

    +
    +
    +

    Review lane

    +

    {displayLabel}

    +
    +

    + {count} +

    { drop(node); listInnerRef.current = node; }} - className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'} + className={'flex-1 space-y-3 overflow-y-auto p-3 max-h-[560px]'} onScroll={onScroll} > {data?.map((item) => ( @@ -188,7 +196,7 @@ const KanbanColumn = ({
    ))} {!data?.length && ( -

    No data

    +

    No records in this lane

    )}
    diff --git a/frontend/src/components/LegalOpsPageIntro.tsx b/frontend/src/components/LegalOpsPageIntro.tsx new file mode 100644 index 0000000..38bb123 --- /dev/null +++ b/frontend/src/components/LegalOpsPageIntro.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode } from 'react' + +type PageMetric = { + label: string + value: string + helper: string +} + +type Props = { + eyebrow: string + title: string + description: string + metrics: PageMetric[] + children?: ReactNode +} + +export default function LegalOpsPageIntro({ + eyebrow, + title, + description, + metrics, + children, +}: Props) { + return ( +
    +
    +
    +

    {eyebrow}

    +

    {title}

    +

    {description}

    +
    + {children &&
    {children}
    } +
    +
    + {metrics.map((metric) => ( +
    +

    {metric.label}

    +

    {metric.value}

    +

    {metric.helper}

    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index c270ae0..ac04960 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -5,7 +5,6 @@ import BaseIcon from './BaseIcon' import NavBarItemPlain from './NavBarItemPlain' import NavBarMenuList from './NavBarMenuList' import { MenuNavBarItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks'; type Props = { menu: MenuNavBarItem[] @@ -16,7 +15,6 @@ type Props = { export default function NavBar({ menu, className = '', children }: Props) { const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false) const [isScrolled, setIsScrolled] = useState(false); - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); useEffect(() => { const handleScroll = () => { @@ -35,9 +33,9 @@ export default function NavBar({ menu, className = '', children }: Props) { return (