2
This commit is contained in:
parent
1ef812ac7e
commit
97439eda85
@ -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}:
|
||||
|
||||
@ -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}:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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 ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -273,11 +268,18 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow the register without leaving the governance workspace.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -299,8 +301,8 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -324,9 +326,9 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -337,7 +339,7 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -353,9 +355,9 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -369,7 +371,7 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -382,8 +384,8 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -395,7 +397,7 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -409,17 +411,17 @@ const TableSampleAi_tools = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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 ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -314,11 +309,18 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow the register without leaving the governance workspace.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -340,8 +342,8 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -365,9 +367,9 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -378,7 +380,7 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -394,9 +396,9 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -410,7 +412,7 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -423,8 +425,8 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -436,7 +438,7 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -450,17 +452,17 @@ const TableSampleAi_use_cases = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -273,11 +268,18 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow the register without leaving the governance workspace.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -299,8 +301,8 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -324,9 +326,9 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -337,7 +339,7 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -353,9 +355,9 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -369,7 +371,7 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -382,8 +384,8 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -395,7 +397,7 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -409,17 +411,17 @@ const TableSampleApproval_steps = ({ filterItems, setFilterItems, filters, showG
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -19,7 +19,7 @@ export default function AsideMenu({
|
||||
<>
|
||||
<AsideMenuLayer
|
||||
menu={props.menu}
|
||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
|
||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-80 lg:left-0'} ${
|
||||
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
|
||||
}`}
|
||||
onAsideLgCloseClick={props.onAsideLgClose}
|
||||
|
||||
@ -15,10 +15,9 @@ type Props = {
|
||||
|
||||
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
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 && (
|
||||
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
||||
<BaseIcon path={item.icon} className={`flex-none ${activeClassAddon}`} size="18" />
|
||||
)}
|
||||
<span
|
||||
className={`grow text-ellipsis line-clamp-1 ${
|
||||
item.menu ? '' : 'pr-12'
|
||||
} ${activeClassAddon}`}
|
||||
className={`min-w-0 flex-1 whitespace-normal break-words leading-5 ${activeClassAddon}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@ -56,25 +71,25 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
<BaseIcon
|
||||
path={isDropdownActive ? mdiMinus : mdiPlus}
|
||||
className={`flex-none ${activeClassAddon}`}
|
||||
w="w-12"
|
||||
w="w-5"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
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 (
|
||||
<li className={'px-3 py-1.5'}>
|
||||
<li className={isDropdownList ? 'px-0 py-1' : 'px-3 py-1'}>
|
||||
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
||||
{item.href && (
|
||||
<Link href={item.href} target={item.target} className={componentClass}>
|
||||
@ -89,8 +104,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
{item.menu && (
|
||||
<AsideMenuList
|
||||
menu={item.menu}
|
||||
className={`${asideMenuDropdownStyle} ${
|
||||
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
|
||||
className={`mb-2 ml-7 mt-1 border-l border-[#DDD5C7] pl-3 dark:border-slate-700 ${
|
||||
isDropdownActive ? 'block' : 'hidden'
|
||||
}`}
|
||||
isDropdownList
|
||||
/>
|
||||
|
||||
@ -29,19 +29,21 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
return (
|
||||
<aside
|
||||
id='asideMenu'
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
className={`${className} zzz fixed top-0 z-40 flex h-screen w-80 overflow-hidden transition-position lg:py-3 lg:pl-3`}
|
||||
>
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
className={`flex-1 flex flex-col overflow-hidden border border-[#E5E0D6] shadow-xl shadow-[#83755E]/10 dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||
className={`flex flex-row items-center justify-between px-5 py-5 ${asideBrandStyle}`}
|
||||
>
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">Legal AI Governance Hub</b>
|
||||
|
||||
|
||||
<div className="flex-1 text-left">
|
||||
<b className="block text-lg font-black leading-tight text-[#0E1A2B] dark:text-white">
|
||||
Legal AI Governance Hub
|
||||
</b>
|
||||
<span className="mt-1 block text-xs font-semibold text-[#7A5B13] dark:text-slate-400">
|
||||
AI adoption control plane
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
|
||||
@ -16,7 +16,7 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<ul className={className}>
|
||||
<ul className={`${className} ${isDropdownList ? '' : 'py-2'}`}>
|
||||
{menu.map((item, index) => {
|
||||
|
||||
if (!hasPermission(currentUser, item.permissions)) return null;
|
||||
|
||||
@ -37,12 +37,12 @@ export default function CardBox({
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||
const componentClass = [
|
||||
`flex dark:border-dark-700 dark:bg-dark-900`,
|
||||
`flex dark:border-dark-700 dark:bg-dark-900`,
|
||||
className,
|
||||
corners !== 'rounded-full'? corners : 'rounded-3xl',
|
||||
flex,
|
||||
isList ? '' : `${cardsStyle}`,
|
||||
hasTable ? '' : `border-dark-700 dark:border-dark-700`,
|
||||
hasTable ? '' : `dark:border-dark-700`,
|
||||
]
|
||||
|
||||
if (isHoverable) {
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import React from 'react';
|
||||
import CardBox from '../CardBox';
|
||||
import ImageField from '../ImageField';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import ListActionsPopover from "../ListActionsPopover";
|
||||
import {useAppSelector} from "../../stores/hooks";
|
||||
import {Pagination} from "../Pagination";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import CardBox from '../CardBox';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import ListActionsPopover from "../ListActionsPopover";
|
||||
import { useAppSelector } from "../../stores/hooks";
|
||||
import { Pagination } from "../Pagination";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import { hasPermission } from "../../helpers/userPermissions";
|
||||
|
||||
type Props = {
|
||||
checklist_items: any[];
|
||||
@ -21,92 +17,109 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const ListChecklist_items = ({ checklist_items, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CHECKLIST_ITEMS')
|
||||
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const formatChecklist = (checklist: any) => {
|
||||
const value = dataFormatter.human_review_checklistsOneListFormatter(checklist);
|
||||
|
||||
if (!value) {
|
||||
return 'Checklist not linked';
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const ListChecklist_items = ({ checklist_items, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CHECKLIST_ITEMS');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative overflow-x-auto p-4 space-y-4'>
|
||||
{loading && <LoadingSpinner />}
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(340px,1fr))] gap-4 p-4'>
|
||||
{loading && (
|
||||
<div className='col-span-full rounded-xl border border-[#DDD5C7] bg-white p-10'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && checklist_items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<CardBox hasTable isList className={'rounded shadow-none'}>
|
||||
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
|
||||
|
||||
<Link
|
||||
href={`/checklist_items/checklist_items-view/?id=${item.id}`}
|
||||
className={
|
||||
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Checklist</p>
|
||||
<p className={'line-clamp-2'}>{ dataFormatter.human_review_checklistsOneListFormatter(item.checklist) }</p>
|
||||
<CardBox
|
||||
key={item.id}
|
||||
hasComponentLayout
|
||||
className='overflow-hidden border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10 transition hover:border-[#D8B75E]/70 hover:shadow-md'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4 border-b border-[#E5E0D6] bg-[#FBF8F1] p-5'>
|
||||
<div className='flex min-w-0 gap-4'>
|
||||
<div className='flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-[#0E1A2B] text-sm font-semibold text-[#D8B75E]'>
|
||||
#{item.item_order || '-'}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>
|
||||
Review item
|
||||
</p>
|
||||
<Link
|
||||
href={`/checklist_items/checklist_items-view/?id=${item.id}`}
|
||||
className='mt-2 block text-lg font-semibold leading-6 text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.item_text || 'Untitled review item'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Item</p>
|
||||
<p className={'line-clamp-2'}>{ item.item_text }</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Order</p>
|
||||
<p className={'line-clamp-2'}>{ item.item_order }</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Required</p>
|
||||
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_required) }</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</Link>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/checklist_items/checklist_items-edit/?id=${item.id}`}
|
||||
pathView={`/checklist_items/checklist_items-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/checklist_items/checklist_items-edit/?id=${item.id}`}
|
||||
pathView={`/checklist_items/checklist_items-view/?id=${item.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 p-5'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<span className='rounded-full border border-[#D8D0C2] bg-[#F6F3EC] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>
|
||||
Order {item.item_order || 'not set'}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
item.is_required
|
||||
? 'rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-semibold text-red-700'
|
||||
: 'rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600'
|
||||
}
|
||||
>
|
||||
{item.is_required ? 'Required' : 'Optional'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>
|
||||
Linked checklist
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-semibold leading-6 text-[#0E1A2B]'>
|
||||
{formatChecklist(item.checklist)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className='text-sm leading-6 text-[#5B6472]'>
|
||||
This step becomes part of the human oversight record before AI-assisted work is relied on or sent outside the legal team.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && checklist_items.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
<div className='col-span-full flex h-40 items-center justify-center rounded-xl border border-dashed border-[#D8D0C2] bg-white'>
|
||||
<p className='text-sm text-[#5B6472]'>No checklist items to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default ListChecklist_items
|
||||
export default ListChecklist_items
|
||||
|
||||
@ -44,9 +44,6 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
|
||||
const { checklist_items, loading, count, notify: checklist_itemsNotify, refetch } = useAppSelector((state) => state.checklist_items)
|
||||
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);
|
||||
@ -204,9 +201,7 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
};
|
||||
|
||||
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 = (
|
||||
@ -264,7 +259,7 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -275,11 +270,18 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow checklist controls by item text, review order, or linked checklist.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -301,8 +303,8 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -326,9 +328,9 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -339,7 +341,7 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -355,9 +357,9 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -371,7 +373,7 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -384,8 +386,8 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -397,7 +399,7 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -411,17 +413,17 @@ const TableSampleChecklist_items = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,150 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import useDevCompilationStatus from '../hooks/useDevCompilationStatus';
|
||||
const DevModeBadge: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const compilationStatus = useDevCompilationStatus();
|
||||
|
||||
const [badgeStyles, setBadgeStyles] = useState<React.CSSProperties>({
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '70px',
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
color: 'white',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
textAlign: 'left',
|
||||
zIndex: 2147483647,
|
||||
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
transition: 'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
width: '340px',
|
||||
maxWidth: '340px',
|
||||
height: 'auto',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const fullText = `🚧 Your app is running in development mode.
|
||||
Current request is compiling and may take a few moments.
|
||||
|
||||
💡 Tip: Set up a stable environment to run your app in production mode—pages will load instantly without compilation delays.`;
|
||||
|
||||
const collapsedText = '🚧 DEV stage';
|
||||
|
||||
useEffect(() => {
|
||||
if (compilationStatus === 'ready') {
|
||||
setIsCollapsed(true);
|
||||
} else {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
|
||||
}, [compilationStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') {
|
||||
setIsVisible(true);
|
||||
|
||||
setBadgeStyles(prev => ({
|
||||
...prev,
|
||||
opacity: 1,
|
||||
width: '120px',
|
||||
maxWidth: '120px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '18px',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
}));
|
||||
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setBadgeStyles(prev => ({ ...prev, opacity: 0 }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
if (isCollapsed) {
|
||||
setBadgeStyles(prev => ({
|
||||
...prev,
|
||||
width: '140px',
|
||||
maxWidth: '160px',
|
||||
padding: '6px 20px',
|
||||
borderRadius: '18px',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '12px',
|
||||
}));
|
||||
} else {
|
||||
setBadgeStyles(prev => ({
|
||||
...prev,
|
||||
width: '340px',
|
||||
maxWidth: '340px',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '14px',
|
||||
}));
|
||||
}
|
||||
}, [isCollapsed, isVisible]);
|
||||
|
||||
const handleToggleCollapse = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsCollapsed(prev => !prev);
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={badgeStyles} onClick={isCollapsed ? handleToggleCollapse : undefined}>
|
||||
<button
|
||||
onClick={handleToggleCollapse}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: isCollapsed ? '3px' : '5px',
|
||||
right: isCollapsed ? '2px' : '5px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: isCollapsed ? '10px' : '18px',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: '1',
|
||||
width: '24px',
|
||||
height: isCollapsed ? '24px' : '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
transition: 'background-color 0.2s ease, font-size 0.2s ease, width 0.2s ease, height 0.2s ease',
|
||||
}}
|
||||
aria-label={isCollapsed ? "Expand message" : "Collapse message"}
|
||||
>
|
||||
{isCollapsed ? '+' : '×'}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div style={{ marginRight: '20px' }}>
|
||||
{fullText}
|
||||
</div>
|
||||
)}
|
||||
{isCollapsed && (
|
||||
<div style={{ marginRight: '10px' }}>
|
||||
{collapsedText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const DevModeBadge = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default DevModeBadge;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import { containerMaxW } from '../config'
|
||||
import Logo from './Logo'
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode
|
||||
@ -10,25 +9,13 @@ export default function FooterBar({ children }: Props) {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className={`py-2 px-6 ${containerMaxW}`}>
|
||||
<div className="block md:flex items-center justify-between">
|
||||
<div className="text-center md:text-left mb-6 md:mb-0">
|
||||
<b>
|
||||
©{year},{` `}
|
||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
||||
Flatlogic
|
||||
</a>
|
||||
.
|
||||
</b>
|
||||
{` `}
|
||||
{children}
|
||||
<footer className={`border-t border-[#DDD5C7] px-5 py-5 text-sm text-[#6F6657] lg:px-8 ${containerMaxW}`}>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<b className='text-[#0E1A2B]'>© {year} Legal AI Governance Hub.</b>
|
||||
<span className='ml-1'>{children}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex item-center md:py-2 gap-4">
|
||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
||||
<Logo className="w-auto h-8 md:h-6 mx-auto" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="font-medium text-[#7A5B13]">Audit-ready AI adoption workspace</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
import Link from 'next/link';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { Pagination } from '../Pagination';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import { saveFile } from "../../helpers/fileSaver";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import { hasPermission } from "../../helpers/userPermissions";
|
||||
|
||||
type Props = {
|
||||
integrations: any[];
|
||||
@ -20,6 +17,40 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const formatValue = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const statusClassName = (status?: string) => {
|
||||
if (status === 'configured') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||
}
|
||||
|
||||
if (status === 'available') {
|
||||
return 'border-[#D8B75E] bg-[#FFF8DF] text-[#7A5B13]';
|
||||
}
|
||||
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
};
|
||||
|
||||
const categoryToneClassName = (category?: string) => {
|
||||
if (category === 'identity' || category === 'ai_provider') {
|
||||
return 'bg-[#0E1A2B] text-[#D8B75E]';
|
||||
}
|
||||
|
||||
if (category === 'dms' || category === 'case_management') {
|
||||
return 'bg-[#EEF2FF] text-[#3730A3]';
|
||||
}
|
||||
|
||||
return 'bg-[#F6F3EC] text-[#7A5B13]';
|
||||
};
|
||||
|
||||
const CardIntegrations = ({
|
||||
integrations,
|
||||
loading,
|
||||
@ -28,143 +59,112 @@ const CardIntegrations = ({
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_INTEGRATIONS')
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_INTEGRATIONS');
|
||||
|
||||
return (
|
||||
<div className={'p-4'}>
|
||||
{loading && <LoadingSpinner />}
|
||||
<div className='p-4'>
|
||||
{loading && (
|
||||
<div className='rounded-xl border border-[#DDD5C7] bg-white p-10'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
className='grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-4'
|
||||
>
|
||||
{!loading && integrations.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
||||
|
||||
<Link href={`/integrations/integrations-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
{!loading && integrations.map((item) => {
|
||||
const documents = dataFormatter.filesFormatter(item.configuration_files);
|
||||
|
||||
<div className='ml-auto '>
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className='min-w-0 overflow-hidden rounded-xl border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10 transition hover:border-[#D8B75E]/70 hover:shadow-md'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3 border-b border-[#E5E0D6] bg-[#FBF8F1] p-5'>
|
||||
<div className='min-w-0'>
|
||||
<Link
|
||||
href={`/integrations/integrations-view/?id=${item.id}`}
|
||||
className='block truncate text-lg font-semibold leading-6 text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.name || 'Unnamed integration'}
|
||||
</Link>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${categoryToneClassName(item.category)}`}>
|
||||
{formatValue(item.category)}
|
||||
</span>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-xs font-semibold ${statusClassName(item.integration_status)}`}>
|
||||
{formatValue(item.integration_status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/integrations/integrations-edit/?id=${item.id}`}
|
||||
pathView={`/integrations/integrations-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>IntegrationName</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.name }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Category</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.category }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='space-y-4 p-5'>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-3'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Status</p>
|
||||
<p className='mt-2 text-sm font-semibold text-[#0E1A2B]'>{formatValue(item.integration_status)}</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-3'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Enabled</p>
|
||||
<p className='mt-2 text-sm font-semibold text-[#0E1A2B]'>
|
||||
{item.is_enabled ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Status</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.integration_status }
|
||||
</div>
|
||||
</dd>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Configuration notes</p>
|
||||
<p className='mt-2 min-h-[48px] text-sm leading-6 text-[#5B6472]'>
|
||||
{item.configuration_notes || 'No configuration notes yet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Enabled</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.booleanFormatter(item.is_enabled) }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='border-t border-[#E5E0D6] pt-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Configuration files</p>
|
||||
{documents.length > 0 ? (
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{documents.slice(0, 2).map((link) => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
className='rounded-full border border-[#D8D0C2] bg-white px-3 py-1 text-xs font-semibold text-[#0E1A2B] hover:border-[#D8B75E] hover:text-[#7A5B13]'
|
||||
onClick={(event) => saveFile(event, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
{documents.length > 2 && (
|
||||
<span className='rounded-full bg-[#F6F3EC] px-3 py-1 text-xs font-semibold text-[#8B7A61]'>
|
||||
+{documents.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='mt-2 text-sm text-[#5B6472]'>No files attached</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>ConfigurationNotes</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.configuration_notes }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>ConfigurationFiles</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium'>
|
||||
{dataFormatter.filesFormatter(item.configuration_files).map(link => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
{!loading && integrations.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
|
||||
{!loading && integrations.length === 0 && (
|
||||
<div className='flex h-40 items-center justify-center rounded-xl border border-dashed border-[#D8D0C2] bg-white'>
|
||||
<p className='text-sm text-[#5B6472]'>No integrations to display</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
|
||||
@ -26,7 +26,7 @@ const KanbanBoard = ({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'pb-2 flex-grow min-h-[400px] flex-1 grid grid-rows-1 auto-cols-min grid-flow-col gap-x-3 overflow-y-hidden overflow-x-auto'
|
||||
'grid min-h-[520px] flex-1 auto-cols-[minmax(300px,340px)] grid-flow-col gap-x-4 overflow-x-auto overflow-y-hidden pb-4'
|
||||
}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
ref={drag}
|
||||
className={
|
||||
`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
|
||||
`relative space-y-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm transition hover:border-[#D8B75E]/70 hover:shadow-md dark:bg-dark-800 ${isDragging ? 'cursor-grabbing opacity-80' : 'cursor-grab'}`
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Link
|
||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
className={'text-base font-semibold'}
|
||||
className={'pr-3 text-sm font-semibold leading-5 text-[#0E1A2B] hover:text-[#7A5B13]'}
|
||||
>
|
||||
{item[showFieldName] ?? 'No data'}
|
||||
</Link>
|
||||
{statusValue && (
|
||||
<span className={'shrink-0 rounded-full bg-[#F6F3EC] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.1em] text-[#7A5B13]'}>
|
||||
{formatValue(statusValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className={'line-clamp-2 text-xs leading-5 text-[#5B6472]'}>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
||||
<p className={'text-xs font-medium text-[#8B7A61]'}>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
||||
<ListActionsPopover
|
||||
itemId={item.id}
|
||||
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
||||
|
||||
@ -31,8 +31,11 @@ const KanbanColumn = ({
|
||||
showFieldName,
|
||||
filtersQuery,
|
||||
deleteThunk,
|
||||
updateThunk,
|
||||
updateThunk,
|
||||
}: Props) => {
|
||||
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 = ({
|
||||
<CardBox
|
||||
hasComponentLayout
|
||||
className={
|
||||
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
|
||||
'h-full min-h-[520px] overflow-hidden border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center justify-between p-3'}>
|
||||
<p className={'uppercase'}>{column.label}</p>
|
||||
<p>{count}</p>
|
||||
<div className={'flex items-center justify-between border-b border-[#E5E0D6] bg-white px-4 py-3'}>
|
||||
<div>
|
||||
<p className={'text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'}>Review lane</p>
|
||||
<p className={'mt-1 text-sm font-semibold text-[#0E1A2B]'}>{displayLabel}</p>
|
||||
</div>
|
||||
<p className={'rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'}>
|
||||
{count}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={(node) => {
|
||||
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 = ({
|
||||
</div>
|
||||
))}
|
||||
{!data?.length && (
|
||||
<p className={'text-center py-8 bg-gray-50 dark:bg-dark-800'}>No data</p>
|
||||
<p className={'rounded-lg border border-dashed border-[#D8D0C2] bg-white px-4 py-8 text-center text-sm text-[#8B7A61] dark:bg-dark-800'}>No records in this lane</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
48
frontend/src/components/LegalOpsPageIntro.tsx
Normal file
48
frontend/src/components/LegalOpsPageIntro.tsx
Normal file
@ -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 (
|
||||
<section className='mb-6 overflow-hidden rounded-xl border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10'>
|
||||
<div className='grid gap-5 p-6 lg:grid-cols-[1fr_auto] lg:items-start'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-[#7A5B13]'>{eyebrow}</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold text-[#0E1A2B]'>{title}</h2>
|
||||
<p className='mt-3 max-w-3xl text-sm leading-6 text-[#5B6472]'>{description}</p>
|
||||
</div>
|
||||
{children && <div className='flex flex-wrap gap-2 lg:max-w-xl lg:justify-end'>{children}</div>}
|
||||
</div>
|
||||
<div className='grid border-t border-[#E5E0D6] bg-[#FBF8F1] md:grid-cols-3'>
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className='border-b border-[#E5E0D6] p-5 last:border-b-0 md:border-b-0 md:border-r md:last:border-r-0'
|
||||
>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>{metric.label}</p>
|
||||
<p className='mt-2 text-xl font-semibold text-[#0E1A2B]'>{metric.value}</p>
|
||||
<p className='mt-1 text-xs leading-5 text-[#6B7280]'>{metric.helper}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<nav
|
||||
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
|
||||
className={`${className} fixed inset-x-0 top-0 z-30 h-14 w-screen border-b border-[#DDD5C7] bg-[#F6F3EC]/95 backdrop-blur transition-position lg:w-auto dark:bg-dark-800`}
|
||||
>
|
||||
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
|
||||
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled ? 'shadow-sm shadow-[#83755E]/10 dark:border-dark-700' : ''}`}>
|
||||
<div className="flex flex-1 items-stretch h-14">{children}</div>
|
||||
<div className="flex-none items-stretch flex h-14 lg:hidden">
|
||||
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
||||
@ -47,7 +45,7 @@ export default function NavBar({ menu, className = '', children }: Props) {
|
||||
<div
|
||||
className={`${
|
||||
isMenuNavBarActive ? 'block' : 'hidden'
|
||||
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
|
||||
} absolute left-0 top-14 flex max-h-screen-menu w-screen items-center overflow-y-auto bg-[#F6F3EC] shadow-lg lg:static lg:flex lg:w-auto lg:overflow-visible lg:bg-transparent lg:shadow-none dark:bg-dark-800`}
|
||||
>
|
||||
<NavBarMenuList menu={menu} />
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
@ -39,7 +38,7 @@ export default function NavBarItem({ item }: Props) {
|
||||
}, [router.pathname]);
|
||||
|
||||
const componentClass = [
|
||||
'block lg:flex items-center relative cursor-pointer',
|
||||
'block lg:flex items-center relative cursor-pointer text-sm font-medium',
|
||||
isDropdownActive
|
||||
? `${navBarItemLabelActiveColorStyle} dark:text-slate-400`
|
||||
: `${navBarItemLabelStyle} dark:text-white dark:hover:text-slate-400 ${navBarItemLabelHoverStyle}`,
|
||||
@ -81,7 +80,7 @@ export default function NavBarItem({ item }: Props) {
|
||||
id={getItemId(itemLabel)}
|
||||
className={`flex items-center ${
|
||||
item.menu
|
||||
? 'bg-gray-100 dark:bg-dark-800 lg:bg-transparent lg:dark:bg-transparent p-3 lg:p-0'
|
||||
? 'bg-[#FBF8F1] dark:bg-dark-800 lg:bg-transparent lg:dark:bg-transparent p-3 lg:p-0'
|
||||
: 'w-full'
|
||||
}`}
|
||||
onClick={handleMenuClick}
|
||||
@ -106,7 +105,7 @@ export default function NavBarItem({ item }: Props) {
|
||||
<div
|
||||
className={`${
|
||||
!isDropdownActive ? 'lg:hidden' : ''
|
||||
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-dark-900 dark:border-dark-700`}
|
||||
} text-sm border-b border-[#DDD5C7] lg:border lg:border-[#DDD5C7] lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-xl lg:shadow-[#83755E]/10 lg:dark:bg-dark-900 dark:border-dark-700`}
|
||||
>
|
||||
<ClickOutside onClickOutside={() => setIsDropdownActive(false)} excludedElements={[excludedRef]}>
|
||||
<NavBarMenuList menu={item.menu} />
|
||||
|
||||
@ -17,7 +17,7 @@ export default function NavBarItemPlain({
|
||||
const navBarItemLabelStyle = useAppSelector((state) => state.style.navBarItemLabelStyle)
|
||||
const navBarItemLabelHoverStyle = useAppSelector((state) => state.style.navBarItemLabelHoverStyle)
|
||||
|
||||
const classBase = 'items-center cursor-pointer dark:text-white dark:hover:text-slate-400'
|
||||
const classBase = 'items-center cursor-pointer rounded-lg dark:text-white dark:hover:text-slate-400'
|
||||
const classAddon = `${display} ${navBarItemLabelStyle} ${navBarItemLabelHoverStyle} ${
|
||||
useMargin ? 'my-2 mx-3' : 'py-2 px-3'
|
||||
}`
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
import Link from 'next/link';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { Pagination } from '../Pagination';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import { hasPermission } from "../../helpers/userPermissions";
|
||||
|
||||
type Props = {
|
||||
practice_groups: any[];
|
||||
@ -20,6 +16,19 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const getInitials = (name?: string) => {
|
||||
if (!name) {
|
||||
return 'PG';
|
||||
}
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const CardPractice_groups = ({
|
||||
practice_groups,
|
||||
loading,
|
||||
@ -28,112 +37,98 @@ const CardPractice_groups = ({
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PRACTICE_GROUPS')
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PRACTICE_GROUPS');
|
||||
|
||||
return (
|
||||
<div className={'p-4'}>
|
||||
{loading && <LoadingSpinner />}
|
||||
<div className='p-4'>
|
||||
{loading && (
|
||||
<div className='rounded-xl border border-[#DDD5C7] bg-white p-10'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
className='grid grid-cols-[repeat(auto-fit,minmax(340px,1fr))] gap-4'
|
||||
>
|
||||
{!loading && practice_groups.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
||||
|
||||
<Link href={`/practice_groups/practice_groups-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
{!loading && practice_groups.map((item) => {
|
||||
const leadName = dataFormatter.usersOneListFormatter(item.lead_user);
|
||||
|
||||
<div className='ml-auto '>
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className='min-w-0 overflow-hidden rounded-xl border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10 transition hover:border-[#D8B75E]/70 hover:shadow-md'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4 border-b border-[#E5E0D6] bg-[#FBF8F1] p-5'>
|
||||
<div className='flex min-w-0 gap-4'>
|
||||
<div className='flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-[#0E1A2B] text-sm font-semibold text-[#D8B75E]'>
|
||||
{getInitials(item.name)}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>Practice group</p>
|
||||
<Link
|
||||
href={`/practice_groups/practice_groups-view/?id=${item.id}`}
|
||||
className='mt-2 block text-lg font-semibold leading-6 text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.name || 'Unnamed practice group'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/practice_groups/practice_groups-edit/?id=${item.id}`}
|
||||
pathView={`/practice_groups/practice_groups-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>PracticeGroupName</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.name }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.description }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='space-y-4 p-5'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<span
|
||||
className={
|
||||
item.is_active
|
||||
? 'rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700'
|
||||
: 'rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600'
|
||||
}
|
||||
>
|
||||
{item.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<span className='rounded-full border border-[#D8D0C2] bg-[#F6F3EC] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>
|
||||
AI governance owner group
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>PracticeGroupLead</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.usersOneListFormatter(item.lead_user) }
|
||||
</div>
|
||||
</dd>
|
||||
<p className='min-h-[72px] text-sm leading-6 text-[#5B6472]'>
|
||||
{item.description || 'No practice-group description yet.'}
|
||||
</p>
|
||||
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>
|
||||
Practice lead
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-semibold leading-6 text-[#0E1A2B]'>
|
||||
{leadName || 'Lead not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
{!loading && practice_groups.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
<p className='text-sm leading-6 text-[#5B6472]'>
|
||||
Use this group to route AI use cases, reviewer ownership, and policy accountability to the right legal team.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
|
||||
{!loading && practice_groups.length === 0 && (
|
||||
<div className='flex h-40 items-center justify-center rounded-xl border border-dashed border-[#D8D0C2] bg-white'>
|
||||
<p className='text-sm text-[#5B6472]'>No practice groups to display</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
|
||||
@ -44,9 +44,6 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
|
||||
const { practice_groups, loading, count, notify: practice_groupsNotify, refetch } = useAppSelector((state) => state.practice_groups)
|
||||
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);
|
||||
@ -204,9 +201,7 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
};
|
||||
|
||||
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 = (
|
||||
@ -264,7 +259,7 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -275,11 +270,18 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow practice groups by name, description, or lead owner.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Ownership scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -301,8 +303,8 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -326,9 +328,9 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -339,7 +341,7 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -355,9 +357,9 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -371,7 +373,7 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -384,8 +386,8 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -397,7 +399,7 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -411,17 +413,17 @@ const TableSamplePractice_groups = ({ filterItems, setFilterItems, filters, show
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,6 @@ const Search = () => {
|
||||
const router = useRouter();
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||
const validateSearch = (value) => {
|
||||
let error;
|
||||
if (!value) {
|
||||
@ -31,13 +30,13 @@ const Search = () => {
|
||||
validateOnChange={false}
|
||||
>
|
||||
{({ errors, touched, values }) => (
|
||||
<Form style={{width: '300px'}} >
|
||||
<Form className='w-[260px] xl:w-[360px]'>
|
||||
<Field
|
||||
id='search'
|
||||
name='search'
|
||||
validate={validateSearch}
|
||||
placeholder='Search'
|
||||
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
|
||||
placeholder='Search governance records'
|
||||
className={`${corners} relative ml-2 w-full border-[#DDD5C7] bg-white/80 p-2.5 text-sm text-[#111827] placeholder-[#8A8173] shadow-none dark:bg-dark-900 dark:placeholder-dark-600 ${focusRing}`}
|
||||
/>
|
||||
{errors.search && touched.search && values.search.length < 2 ? (
|
||||
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div>
|
||||
|
||||
@ -6,5 +6,5 @@ type Props = {
|
||||
}
|
||||
|
||||
export default function SectionMain({ children }: Props) {
|
||||
return <section className={`p-6 ${containerMaxW}`}>{children}</section>
|
||||
return <section className={`px-5 py-7 lg:px-8 ${containerMaxW}`}>{children}</section>
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { mdiCog } from '@mdi/js'
|
||||
import React, { Children, ReactNode } from 'react'
|
||||
import BaseButton from './BaseButton'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import IconRounded from './IconRounded'
|
||||
import { humanize } from '../helpers/humanize';
|
||||
@ -16,14 +14,23 @@ export default function SectionTitleLineWithButton({ icon, title, main = false,
|
||||
const hasChildren = !!Children.count(children)
|
||||
|
||||
return (
|
||||
<section className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}>
|
||||
<section className={`${main ? '' : 'pt-6'} mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between`}>
|
||||
<div className="flex items-center justify-start">
|
||||
{icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />}
|
||||
{icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />}
|
||||
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
|
||||
{icon && main && <IconRounded icon={icon} color="info" className="mr-3 rounded-lg text-[#D8B75E]" bg />}
|
||||
{icon && !main && <BaseIcon path={icon} className="mr-2 text-[#7A5B13]" size="20" />}
|
||||
<div>
|
||||
{main && (
|
||||
<p className='mb-1 text-xs font-semibold uppercase tracking-[0.16em] text-[#7A5B13]'>
|
||||
Governance workspace
|
||||
</p>
|
||||
)}
|
||||
<h1 className={`leading-tight text-[#0E1A2B] ${main ? 'text-3xl font-semibold' : 'text-2xl font-semibold'}`}>
|
||||
{humanize(title)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />}
|
||||
{!hasChildren && null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
import Link from 'next/link';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { Pagination } from '../Pagination';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import { saveFile } from "../../helpers/fileSaver";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import { hasPermission } from "../../helpers/userPermissions";
|
||||
|
||||
type Props = {
|
||||
training_courses: any[];
|
||||
@ -20,6 +17,52 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const formatValue = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const statusClassName = (status?: string) => {
|
||||
if (status === 'active') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||
}
|
||||
|
||||
if (status === 'draft') {
|
||||
return 'border-[#D8B75E] bg-[#FFF8DF] text-[#7A5B13]';
|
||||
}
|
||||
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
};
|
||||
|
||||
const deliveryClassName = (deliveryType?: string) => {
|
||||
if (deliveryType === 'live') {
|
||||
return 'bg-[#0E1A2B] text-[#D8B75E]';
|
||||
}
|
||||
|
||||
if (deliveryType === 'video' || deliveryType === 'lms_link') {
|
||||
return 'bg-[#EEF2FF] text-[#3730A3]';
|
||||
}
|
||||
|
||||
return 'bg-[#F6F3EC] text-[#7A5B13]';
|
||||
};
|
||||
|
||||
const getCourseHref = (link?: string) => {
|
||||
if (!link) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (link.startsWith('http')) {
|
||||
return link;
|
||||
}
|
||||
|
||||
return `https://${link}`;
|
||||
};
|
||||
|
||||
const CardTraining_courses = ({
|
||||
training_courses,
|
||||
loading,
|
||||
@ -28,167 +71,129 @@ const CardTraining_courses = ({
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TRAINING_COURSES')
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TRAINING_COURSES');
|
||||
|
||||
return (
|
||||
<div className={'p-4'}>
|
||||
{loading && <LoadingSpinner />}
|
||||
<div className='p-4'>
|
||||
{loading && (
|
||||
<div className='rounded-xl border border-[#DDD5C7] bg-white p-10'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
className='grid grid-cols-[repeat(auto-fit,minmax(340px,1fr))] gap-4'
|
||||
>
|
||||
{!loading && training_courses.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
||||
|
||||
<Link href={`/training_courses/training_courses-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
{!loading && training_courses.map((item) => {
|
||||
const attachments = dataFormatter.filesFormatter(item.attachments);
|
||||
const courseHref = getCourseHref(item.link);
|
||||
|
||||
<div className='ml-auto '>
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className='min-w-0 overflow-hidden rounded-xl border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10 transition hover:border-[#D8B75E]/70 hover:shadow-md'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4 border-b border-[#E5E0D6] bg-[#FBF8F1] p-5'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>Training course</p>
|
||||
<Link
|
||||
href={`/training_courses/training_courses-view/?id=${item.id}`}
|
||||
className='mt-2 block text-lg font-semibold leading-6 text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.name || 'Untitled course'}
|
||||
</Link>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${deliveryClassName(item.delivery_type)}`}>
|
||||
{formatValue(item.delivery_type)}
|
||||
</span>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-xs font-semibold ${statusClassName(item.status)}`}>
|
||||
{formatValue(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/training_courses/training_courses-edit/?id=${item.id}`}
|
||||
pathView={`/training_courses/training_courses-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>CourseName</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.name }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.description }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 p-5'>
|
||||
<p className='min-h-[72px] text-sm leading-6 text-[#5B6472]'>
|
||||
{item.description || 'No course description yet.'}
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>DeliveryType</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.delivery_type }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-3'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Duration</p>
|
||||
<p className='mt-2 text-sm font-semibold text-[#0E1A2B]'>
|
||||
{item.duration_minutes || 0} min
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-3'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Valid for</p>
|
||||
<p className='mt-2 text-sm font-semibold text-[#0E1A2B]'>
|
||||
{item.validity_days || 0} days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Link</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.link }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='border-t border-[#E5E0D6] pt-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Course access</p>
|
||||
{courseHref ? (
|
||||
<a
|
||||
href={courseHref}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='mt-2 block truncate text-sm font-semibold text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.link}
|
||||
</a>
|
||||
) : (
|
||||
<p className='mt-2 text-sm text-[#5B6472]'>No course link configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>DurationMinutes</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.duration_minutes }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='border-t border-[#E5E0D6] pt-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-[#8B7A61]'>Attachments</p>
|
||||
{attachments.length > 0 ? (
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{attachments.slice(0, 2).map((link) => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
className='rounded-full border border-[#D8D0C2] bg-white px-3 py-1 text-xs font-semibold text-[#0E1A2B] hover:border-[#D8B75E] hover:text-[#7A5B13]'
|
||||
onClick={(event) => saveFile(event, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
{attachments.length > 2 && (
|
||||
<span className='rounded-full bg-[#F6F3EC] px-3 py-1 text-xs font-semibold text-[#8B7A61]'>
|
||||
+{attachments.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='mt-2 text-sm text-[#5B6472]'>No attachments</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>ValidityDays</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.validity_days }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Status</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.status }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Attachments</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium'>
|
||||
{dataFormatter.filesFormatter(item.attachments).map(link => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
{!loading && training_courses.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
|
||||
{!loading && training_courses.length === 0 && (
|
||||
<div className='flex h-40 items-center justify-center rounded-xl border border-dashed border-[#D8D0C2] bg-white'>
|
||||
<p className='text-sm text-[#5B6472]'>No training courses to display</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
|
||||
@ -44,9 +44,6 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
const { training_courses, loading, count, notify: training_coursesNotify, refetch } = useAppSelector((state) => state.training_courses)
|
||||
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);
|
||||
@ -204,9 +201,7 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
};
|
||||
|
||||
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 = (
|
||||
@ -264,7 +259,7 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -275,11 +270,18 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow courses by name, delivery type, status, validity, or training link.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Training scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -301,8 +303,8 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -326,9 +328,9 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -339,7 +341,7 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -355,9 +357,9 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -371,7 +373,7 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -384,8 +386,8 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -397,7 +399,7 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -411,17 +413,17 @@ const TableSampleTraining_courses = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -42,9 +42,6 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
|
||||
const { vendor_risk_assessments, loading, count, notify: vendor_risk_assessmentsNotify, refetch } = useAppSelector((state) => state.vendor_risk_assessments)
|
||||
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 TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
};
|
||||
|
||||
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 TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -273,11 +268,18 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow the register without leaving the governance workspace.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -299,8 +301,8 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -324,9 +326,9 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -337,7 +339,7 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -353,9 +355,9 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -369,7 +371,7 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -382,8 +384,8 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -395,7 +397,7 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -409,17 +411,17 @@ const TableSampleVendor_risk_assessments = ({ filterItems, setFilterItems, filte
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
import React from 'react';
|
||||
import CardBox from '../CardBox';
|
||||
import ImageField from '../ImageField';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import ListActionsPopover from "../ListActionsPopover";
|
||||
import {useAppSelector} from "../../stores/hooks";
|
||||
import {Pagination} from "../Pagination";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import CardBox from '../CardBox';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { saveFile } from '../../helpers/fileSaver';
|
||||
import ListActionsPopover from "../ListActionsPopover";
|
||||
import { useAppSelector } from "../../stores/hooks";
|
||||
import { Pagination } from "../Pagination";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import { hasPermission } from "../../helpers/userPermissions";
|
||||
|
||||
type Props = {
|
||||
vendors: any[];
|
||||
@ -21,123 +18,182 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const ListVendors = ({ vendors, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_VENDORS')
|
||||
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const formatStatus = (status?: string) => {
|
||||
if (!status) {
|
||||
return 'Unclassified';
|
||||
}
|
||||
|
||||
return status
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const statusClassName = (status?: string) => {
|
||||
if (status === 'active') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||
}
|
||||
|
||||
if (status === 'in_review') {
|
||||
return 'border-[#D8B75E] bg-[#FFF8DF] text-[#7A5B13]';
|
||||
}
|
||||
|
||||
if (status === 'suspended') {
|
||||
return 'border-red-200 bg-red-50 text-red-700';
|
||||
}
|
||||
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
};
|
||||
|
||||
const getVendorInitials = (name?: string) => {
|
||||
if (!name) {
|
||||
return 'V';
|
||||
}
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const getWebsiteHref = (website?: string) => {
|
||||
if (!website) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (website.startsWith('http')) {
|
||||
return website;
|
||||
}
|
||||
|
||||
return `https://${website}`;
|
||||
};
|
||||
|
||||
const ListVendors = ({ vendors, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_VENDORS');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative overflow-x-auto p-4 space-y-4'>
|
||||
{loading && <LoadingSpinner />}
|
||||
{!loading && vendors.map((item) => (
|
||||
<div key={item.id}>
|
||||
<CardBox hasTable isList className={'rounded shadow-none'}>
|
||||
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
|
||||
|
||||
<Link
|
||||
href={`/vendors/vendors-view/?id=${item.id}`}
|
||||
className={
|
||||
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>VendorName</p>
|
||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 p-4'>
|
||||
{loading && (
|
||||
<div className='rounded-xl border border-[#DDD5C7] bg-white p-10'>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Website</p>
|
||||
<p className={'line-clamp-2'}>{ item.website }</p>
|
||||
</div>
|
||||
|
||||
{!loading && vendors.map((item) => {
|
||||
const documents = dataFormatter.filesFormatter(item.contract_documents);
|
||||
const websiteHref = getWebsiteHref(item.website);
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>VendorStatus</p>
|
||||
<p className={'line-clamp-2'}>{ item.vendor_status }</p>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<CardBox
|
||||
key={item.id}
|
||||
hasComponentLayout
|
||||
className='overflow-hidden border border-[#DDD5C7] bg-white shadow-sm shadow-[#83755E]/10 transition hover:border-[#D8B75E]/70 hover:shadow-md'
|
||||
>
|
||||
<div className='grid gap-5 p-5 xl:grid-cols-[minmax(320px,1fr)_minmax(220px,0.7fr)_minmax(220px,0.7fr)_auto] xl:items-center'>
|
||||
<div className='flex min-w-0 gap-4'>
|
||||
<div className='flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-[#0E1A2B] text-sm font-semibold text-[#D8B75E]'>
|
||||
{getVendorInitials(item.name)}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Link
|
||||
href={`/vendors/vendors-view/?id=${item.id}`}
|
||||
className='text-lg font-semibold leading-6 text-[#0E1A2B] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.name || 'Unnamed vendor'}
|
||||
</Link>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-xs font-semibold ${statusClassName(item.vendor_status)}`}>
|
||||
{formatStatus(item.vendor_status)}
|
||||
</span>
|
||||
</div>
|
||||
{websiteHref && (
|
||||
<a
|
||||
href={websiteHref}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='mt-1 block truncate text-sm font-medium text-[#5B6472] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.website}
|
||||
</a>
|
||||
)}
|
||||
{item.notes && (
|
||||
<p className='mt-3 line-clamp-2 max-w-3xl text-sm leading-6 text-[#5B6472]'>
|
||||
{item.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>PrimaryContactName</p>
|
||||
<p className={'line-clamp-2'}>{ item.primary_contact_name }</p>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>Primary contact</p>
|
||||
<p className='mt-2 truncate text-sm font-semibold text-[#0E1A2B]'>{item.primary_contact_name || 'Not assigned'}</p>
|
||||
{item.primary_contact_email && (
|
||||
<a
|
||||
href={`mailto:${item.primary_contact_email}`}
|
||||
className='mt-1 block truncate text-sm text-[#5B6472] hover:text-[#7A5B13]'
|
||||
>
|
||||
{item.primary_contact_email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>PrimaryContactEmail</p>
|
||||
<p className={'line-clamp-2'}>{ item.primary_contact_email }</p>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#8B7A61]'>Contracts</p>
|
||||
{documents.length > 0 ? (
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{documents.slice(0, 2).map((link) => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
className='rounded-full border border-[#D8D0C2] bg-white px-3 py-1 text-xs font-semibold text-[#0E1A2B] hover:border-[#D8B75E] hover:text-[#7A5B13]'
|
||||
onClick={(event) => saveFile(event, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
{documents.length > 2 && (
|
||||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#8B7A61]'>
|
||||
+{documents.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='mt-2 text-sm text-[#5B6472]'>No contract documents</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>ContractDocuments</p>
|
||||
{dataFormatter.filesFormatter(item.contract_documents).map(link => (
|
||||
<button
|
||||
key={link.publicUrl}
|
||||
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
<div className='flex justify-end'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/vendors/vendors-edit/?id=${item.id}`}
|
||||
pathView={`/vendors/vendors-view/?id=${item.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardBox>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Notes</p>
|
||||
<p className={'line-clamp-2'}>{ item.notes }</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</Link>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/vendors/vendors-edit/?id=${item.id}`}
|
||||
pathView={`/vendors/vendors-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
))}
|
||||
{!loading && vendors.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
<div className='flex h-40 items-center justify-center rounded-xl border border-dashed border-[#D8D0C2] bg-white'>
|
||||
<p className='text-sm text-[#5B6472]'>No vendors to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default ListVendors
|
||||
export default ListVendors
|
||||
|
||||
@ -44,9 +44,6 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
|
||||
const { vendors, loading, count, notify: vendorsNotify, refetch } = useAppSelector((state) => state.vendors)
|
||||
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);
|
||||
@ -204,9 +201,7 @@ const TableSampleVendors = ({ 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 = (
|
||||
@ -264,7 +259,7 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-[#DDD5C7] bg-[#FBF8F1] shadow-sm'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -275,11 +270,18 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13]'>Active filters</p>
|
||||
<p className='mt-1 text-sm text-[#5B6472]'>Narrow the register without leaving the governance workspace.</p>
|
||||
</div>
|
||||
<span className='rounded-full border border-[#D8B75E] bg-[#FFF8DF] px-3 py-1 text-xs font-semibold text-[#7A5B13]'>Review scope</span>
|
||||
</div>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="grid gap-3 rounded-lg border border-[#E5E0D6] bg-white p-4 shadow-sm md:grid-cols-[minmax(180px,0.9fr)_minmax(240px,1.4fr)_auto] md:items-end">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -301,8 +303,8 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -326,9 +328,9 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -339,7 +341,7 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -355,9 +357,9 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2'>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -371,7 +373,7 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -384,8 +386,8 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -397,7 +399,7 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[#7A5B13]">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset'
|
||||
@ -411,17 +413,17 @@ const TableSampleVendors = ({ filterItems, setFilterItems, filters, showGrid })
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
color="success"
|
||||
color="info"
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
color='info'
|
||||
label='Cancel'
|
||||
color='whiteDark'
|
||||
label='Reset'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ export const localStorageDarkModeKey = 'darkMode'
|
||||
|
||||
export const localStorageStyleKey = 'style'
|
||||
|
||||
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
|
||||
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-8'
|
||||
|
||||
export const appTitle = 'created by Flatlogic generator!'
|
||||
|
||||
|
||||
171
frontend/src/helpers/legalAiFormatting.ts
Normal file
171
frontend/src/helpers/legalAiFormatting.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { humanize } from './humanize';
|
||||
|
||||
export const useCaseStatusMeta = {
|
||||
draft: {
|
||||
label: 'Draft',
|
||||
className: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
},
|
||||
submitted: {
|
||||
label: 'Submitted',
|
||||
className: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
},
|
||||
risk_review: {
|
||||
label: 'GC review',
|
||||
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
},
|
||||
security_review: {
|
||||
label: 'Security review',
|
||||
className: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
},
|
||||
ethics_review: {
|
||||
label: 'Ethics review',
|
||||
className: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
className: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
className: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
},
|
||||
needs_changes: {
|
||||
label: 'Needs changes',
|
||||
className: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
},
|
||||
retired: {
|
||||
label: 'Retired',
|
||||
className: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
},
|
||||
};
|
||||
|
||||
export const riskLevelMeta = {
|
||||
low: {
|
||||
label: 'Low',
|
||||
className: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
},
|
||||
high: {
|
||||
label: 'High',
|
||||
className: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
className: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
},
|
||||
};
|
||||
|
||||
export const approvalStepMeta = {
|
||||
partner: {
|
||||
label: 'Partner / governance lead',
|
||||
description: 'Business sponsor confirms value and ownership.',
|
||||
},
|
||||
general_counsel: {
|
||||
label: 'General counsel',
|
||||
description: 'Legal leadership confirms risk posture and client impact.',
|
||||
},
|
||||
it_security: {
|
||||
label: 'IT / security',
|
||||
description: 'Security posture, retention, SSO, and controls are validated.',
|
||||
},
|
||||
ethics_risk: {
|
||||
label: 'Ethics / risk',
|
||||
description: 'Human review and professional responsibility safeguards are confirmed.',
|
||||
},
|
||||
};
|
||||
|
||||
export function getBadgeClasses(className = 'bg-slate-100 text-slate-700 border-slate-200') {
|
||||
return `inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${className}`;
|
||||
}
|
||||
|
||||
export function getStatusBadge(value?: string | null) {
|
||||
if (!value) {
|
||||
return {
|
||||
label: 'Not set',
|
||||
className: getBadgeClasses(),
|
||||
};
|
||||
}
|
||||
|
||||
const meta = useCaseStatusMeta[value as keyof typeof useCaseStatusMeta] || {
|
||||
label: humanize(value),
|
||||
className: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
return {
|
||||
label: meta.label,
|
||||
className: getBadgeClasses(meta.className),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRiskBadge(value?: string | null) {
|
||||
if (!value) {
|
||||
return {
|
||||
label: 'Risk pending',
|
||||
className: getBadgeClasses('bg-slate-100 text-slate-700 border-slate-200'),
|
||||
};
|
||||
}
|
||||
|
||||
const meta = riskLevelMeta[value as keyof typeof riskLevelMeta] || {
|
||||
label: humanize(value),
|
||||
className: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
return {
|
||||
label: meta.label,
|
||||
className: getBadgeClasses(meta.className),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDecisionBadge(value?: string | null) {
|
||||
if (!value || value === 'pending') {
|
||||
return {
|
||||
label: 'Pending',
|
||||
className: getBadgeClasses('bg-slate-100 text-slate-700 border-slate-200'),
|
||||
};
|
||||
}
|
||||
|
||||
if (value === 'approved') {
|
||||
return {
|
||||
label: 'Approved',
|
||||
className: getBadgeClasses('bg-emerald-100 text-emerald-700 border-emerald-200'),
|
||||
};
|
||||
}
|
||||
|
||||
if (value === 'rejected') {
|
||||
return {
|
||||
label: 'Rejected',
|
||||
className: getBadgeClasses('bg-rose-100 text-rose-700 border-rose-200'),
|
||||
};
|
||||
}
|
||||
|
||||
if (value === 'needs_changes') {
|
||||
return {
|
||||
label: 'Needs changes',
|
||||
className: getBadgeClasses('bg-orange-100 text-orange-700 border-orange-200'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: humanize(value),
|
||||
className: getBadgeClasses(),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number | string | null) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (Number.isNaN(numericValue)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(numericValue);
|
||||
}
|
||||
@ -11,6 +11,7 @@ export type MenuAsideItem = {
|
||||
target?: string
|
||||
color?: ColorButtonKey
|
||||
isLogout?: boolean
|
||||
isOpenByDefault?: boolean
|
||||
withDevider?: boolean;
|
||||
menu?: MenuAsideItem[]
|
||||
permissions?: string | string[]
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu, mdiShieldCheckOutline } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
import menuNavBar from '../menuNavBar'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
@ -34,6 +33,7 @@ export default function LayoutAuthenticated({
|
||||
const router = useRouter()
|
||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||
const [isAuthReady, setIsAuthReady] = useState(false)
|
||||
let localToken
|
||||
if (typeof window !== 'undefined') {
|
||||
// Perform localStorage action
|
||||
@ -41,20 +41,32 @@ export default function LayoutAuthenticated({
|
||||
}
|
||||
|
||||
const isTokenValid = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (!storedToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date().getTime() / 1000;
|
||||
const data = jwt.decode(token);
|
||||
if (!data) return;
|
||||
const data = jwt.decode(storedToken);
|
||||
|
||||
if (!data || typeof data === 'string' || typeof data.exp !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return date < data.exp;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(findMe());
|
||||
setIsAuthReady(false);
|
||||
|
||||
if (!isTokenValid()) {
|
||||
dispatch(logoutUser());
|
||||
router.push('/login');
|
||||
router.replace(`/login?next=${encodeURIComponent(router.asPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthReady(true);
|
||||
dispatch(findMe());
|
||||
}, [token, localToken]);
|
||||
|
||||
|
||||
@ -86,18 +98,36 @@ export default function LayoutAuthenticated({
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
const layoutAsidePadding = 'xl:pl-80'
|
||||
|
||||
if (!isAuthReady) {
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} min-h-screen bg-[#F6F3EC] text-[#0E1A2B]`}>
|
||||
<div className='flex min-h-screen items-center justify-center px-5'>
|
||||
<div className='w-full max-w-md rounded-xl border border-[#DDD5C7] bg-white p-6 text-center shadow-xl shadow-[#83755E]/10'>
|
||||
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-md bg-[#0E1A2B] text-[#D8B75E]'>
|
||||
<BaseIcon path={mdiShieldCheckOutline} size={26} />
|
||||
</div>
|
||||
<h1 className='mt-5 text-xl font-semibold text-[#0E1A2B]'>Checking secure workspace access</h1>
|
||||
<p className='mt-3 text-sm leading-6 text-[#6B7280]'>
|
||||
We are verifying your session before opening the legal AI governance workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${
|
||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
isAsideMobileExpanded ? 'ml-80 lg:ml-0' : ''
|
||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-80 lg:ml-0' : ''}`}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
@ -111,7 +141,11 @@ export default function LayoutAuthenticated({
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<div className='hidden items-center gap-2 px-4 text-xs font-semibold uppercase tracking-[0.14em] text-[#7A5B13] 2xl:flex'>
|
||||
<span className='h-2 w-2 rounded-full bg-emerald-500' />
|
||||
<span>Audit trail active</span>
|
||||
</div>
|
||||
<NavBarItemPlain display="hidden md:flex" useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</NavBar>
|
||||
@ -122,7 +156,7 @@ export default function LayoutAuthenticated({
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
<FooterBar>Controlled AI adoption for legal teams.</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,202 +1,218 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const icons = icon as unknown as Record<string, string>;
|
||||
|
||||
const getIcon = (name: string, fallback = icon.mdiTable) => icons[name] || fallback;
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
label: 'Command center',
|
||||
icon: getIcon('mdiViewDashboardOutline'),
|
||||
isOpenByDefault: true,
|
||||
menu: [
|
||||
{
|
||||
href: '/governance-workbench',
|
||||
icon: getIcon('mdiShieldCheckOutline'),
|
||||
label: 'Governance workbench',
|
||||
permissions: 'READ_AI_USE_CASES',
|
||||
},
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: getIcon('mdiChartTimelineVariant'),
|
||||
label: 'System overview',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
label: 'AI governance',
|
||||
icon: getIcon('mdiClipboardTextOutline'),
|
||||
isOpenByDefault: true,
|
||||
permissions: ['READ_AI_USE_CASES', 'READ_APPROVAL_STEPS', 'READ_WORKFLOW_RUNS', 'READ_REVIEW_EXCEPTIONS'],
|
||||
menu: [
|
||||
{
|
||||
href: '/ai_use_cases/ai_use_cases-list',
|
||||
icon: getIcon('mdiClipboardTextOutline'),
|
||||
label: 'AI requests',
|
||||
permissions: 'READ_AI_USE_CASES',
|
||||
},
|
||||
{
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
icon: getIcon('mdiCheckDecagram'),
|
||||
label: 'Approval queue',
|
||||
permissions: 'READ_APPROVAL_STEPS',
|
||||
},
|
||||
{
|
||||
href: '/workflow_runs/workflow_runs-list',
|
||||
icon: getIcon('mdiTimelineTextOutline'),
|
||||
label: 'Usage & audit log',
|
||||
permissions: 'READ_WORKFLOW_RUNS',
|
||||
},
|
||||
{
|
||||
href: '/review_exceptions/review_exceptions-list',
|
||||
icon: getIcon('mdiAlertOctagonOutline'),
|
||||
label: 'Review exceptions',
|
||||
permissions: 'READ_REVIEW_EXCEPTIONS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
label: 'Tools & vendors',
|
||||
icon: getIcon('mdiRobot'),
|
||||
isOpenByDefault: true,
|
||||
permissions: ['READ_AI_TOOLS', 'READ_VENDORS', 'READ_VENDOR_RISK_ASSESSMENTS', 'READ_INTEGRATIONS'],
|
||||
menu: [
|
||||
{
|
||||
href: '/ai_tools/ai_tools-list',
|
||||
icon: getIcon('mdiRobot'),
|
||||
label: 'AI tool registry',
|
||||
permissions: 'READ_AI_TOOLS',
|
||||
},
|
||||
{
|
||||
href: '/vendors/vendors-list',
|
||||
icon: getIcon('mdiFactory'),
|
||||
label: 'Vendors',
|
||||
permissions: 'READ_VENDORS',
|
||||
},
|
||||
{
|
||||
href: '/vendor_risk_assessments/vendor_risk_assessments-list',
|
||||
icon: getIcon('mdiShieldSearch'),
|
||||
label: 'Vendor risk',
|
||||
permissions: 'READ_VENDOR_RISK_ASSESSMENTS',
|
||||
},
|
||||
{
|
||||
href: '/integrations/integrations-list',
|
||||
icon: getIcon('mdiConnection'),
|
||||
label: 'Integrations',
|
||||
permissions: 'READ_INTEGRATIONS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/practice_groups/practice_groups-list',
|
||||
label: 'Practice groups',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PRACTICE_GROUPS'
|
||||
label: 'Policies & controls',
|
||||
icon: getIcon('mdiShieldLock'),
|
||||
isOpenByDefault: true,
|
||||
permissions: ['READ_DATA_CLASSIFICATIONS', 'READ_POLICIES', 'READ_HUMAN_REVIEW_CHECKLISTS', 'READ_CHECKLIST_ITEMS'],
|
||||
menu: [
|
||||
{
|
||||
href: '/data_classifications/data_classifications-list',
|
||||
icon: getIcon('mdiShieldLock'),
|
||||
label: 'Data sensitivity',
|
||||
permissions: 'READ_DATA_CLASSIFICATIONS',
|
||||
},
|
||||
{
|
||||
href: '/policies/policies-list',
|
||||
icon: getIcon('mdiFileDocumentOutline'),
|
||||
label: 'Policies & guardrails',
|
||||
permissions: 'READ_POLICIES',
|
||||
},
|
||||
{
|
||||
href: '/human_review_checklists/human_review_checklists-list',
|
||||
icon: getIcon('mdiClipboardCheckOutline'),
|
||||
label: 'Human review checklists',
|
||||
permissions: 'READ_HUMAN_REVIEW_CHECKLISTS',
|
||||
},
|
||||
{
|
||||
href: '/checklist_items/checklist_items-list',
|
||||
icon: getIcon('mdiFormatListCheckbox'),
|
||||
label: 'Checklist items',
|
||||
permissions: 'READ_CHECKLIST_ITEMS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/matter_types/matter_types-list',
|
||||
label: 'Matter types',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBriefcase' in icon ? icon['mdiBriefcase' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_MATTER_TYPES'
|
||||
label: 'Training & access',
|
||||
icon: getIcon('mdiSchoolOutline'),
|
||||
permissions: [
|
||||
'READ_TRAINING_COURSES',
|
||||
'READ_TRAINING_REQUIREMENTS',
|
||||
'READ_USER_TRAINING_RECORDS',
|
||||
'READ_TOOL_ENTITLEMENTS',
|
||||
'READ_PRACTICE_GROUPS',
|
||||
'READ_MATTER_TYPES',
|
||||
'READ_ROLES_CATALOG',
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
href: '/training_courses/training_courses-list',
|
||||
icon: getIcon('mdiSchoolOutline'),
|
||||
label: 'Training courses',
|
||||
permissions: 'READ_TRAINING_COURSES',
|
||||
},
|
||||
{
|
||||
href: '/training_requirements/training_requirements-list',
|
||||
icon: getIcon('mdiClipboardTextOutline'),
|
||||
label: 'Training requirements',
|
||||
permissions: 'READ_TRAINING_REQUIREMENTS',
|
||||
},
|
||||
{
|
||||
href: '/user_training_records/user_training_records-list',
|
||||
icon: getIcon('mdiCertificateOutline'),
|
||||
label: 'Training records',
|
||||
permissions: 'READ_USER_TRAINING_RECORDS',
|
||||
},
|
||||
{
|
||||
href: '/tool_entitlements/tool_entitlements-list',
|
||||
icon: getIcon('mdiKeyVariant'),
|
||||
label: 'Tool access',
|
||||
permissions: 'READ_TOOL_ENTITLEMENTS',
|
||||
},
|
||||
{
|
||||
href: '/practice_groups/practice_groups-list',
|
||||
icon: getIcon('mdiDomain'),
|
||||
label: 'Practice groups',
|
||||
permissions: 'READ_PRACTICE_GROUPS',
|
||||
},
|
||||
{
|
||||
href: '/matter_types/matter_types-list',
|
||||
icon: getIcon('mdiBriefcase'),
|
||||
label: 'Matter types',
|
||||
permissions: 'READ_MATTER_TYPES',
|
||||
},
|
||||
{
|
||||
href: '/roles_catalog/roles_catalog-list',
|
||||
icon: getIcon('mdiAccountGroupOutline'),
|
||||
label: 'Legal roles catalog',
|
||||
permissions: 'READ_ROLES_CATALOG',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/data_classifications/data_classifications-list',
|
||||
label: 'Data classifications',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldLock' in icon ? icon['mdiShieldLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_DATA_CLASSIFICATIONS'
|
||||
},
|
||||
{
|
||||
href: '/ai_tools/ai_tools-list',
|
||||
label: 'Ai tools',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiRobot' in icon ? icon['mdiRobot' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_AI_TOOLS'
|
||||
},
|
||||
{
|
||||
href: '/vendors/vendors-list',
|
||||
label: 'Vendors',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFactory' in icon ? icon['mdiFactory' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VENDORS'
|
||||
},
|
||||
{
|
||||
href: '/ai_use_cases/ai_use_cases-list',
|
||||
label: 'Ai use cases',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_AI_USE_CASES'
|
||||
},
|
||||
{
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
label: 'Approval steps',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCheckDecagram' in icon ? icon['mdiCheckDecagram' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_APPROVAL_STEPS'
|
||||
},
|
||||
{
|
||||
href: '/vendor_risk_assessments/vendor_risk_assessments-list',
|
||||
label: 'Vendor risk assessments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldSearch' in icon ? icon['mdiShieldSearch' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VENDOR_RISK_ASSESSMENTS'
|
||||
},
|
||||
{
|
||||
href: '/policies/policies-list',
|
||||
label: 'Policies',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_POLICIES'
|
||||
},
|
||||
{
|
||||
href: '/human_review_checklists/human_review_checklists-list',
|
||||
label: 'Human review checklists',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardCheckOutline' in icon ? icon['mdiClipboardCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_HUMAN_REVIEW_CHECKLISTS'
|
||||
},
|
||||
{
|
||||
href: '/checklist_items/checklist_items-list',
|
||||
label: 'Checklist items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFormatListCheckbox' in icon ? icon['mdiFormatListCheckbox' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CHECKLIST_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/training_courses/training_courses-list',
|
||||
label: 'Training courses',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSchoolOutline' in icon ? icon['mdiSchoolOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TRAINING_COURSES'
|
||||
},
|
||||
{
|
||||
href: '/training_requirements/training_requirements-list',
|
||||
label: 'Training requirements',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TRAINING_REQUIREMENTS'
|
||||
},
|
||||
{
|
||||
href: '/user_training_records/user_training_records-list',
|
||||
label: 'User training records',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCertificateOutline' in icon ? icon['mdiCertificateOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_USER_TRAINING_RECORDS'
|
||||
},
|
||||
{
|
||||
href: '/tool_entitlements/tool_entitlements-list',
|
||||
label: 'Tool entitlements',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiKeyVariant' in icon ? icon['mdiKeyVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TOOL_ENTITLEMENTS'
|
||||
},
|
||||
{
|
||||
href: '/workflow_runs/workflow_runs-list',
|
||||
label: 'Workflow runs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTimelineTextOutline' in icon ? icon['mdiTimelineTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_WORKFLOW_RUNS'
|
||||
},
|
||||
{
|
||||
href: '/review_exceptions/review_exceptions-list',
|
||||
label: 'Review exceptions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAlertOctagonOutline' in icon ? icon['mdiAlertOctagonOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REVIEW_EXCEPTIONS'
|
||||
},
|
||||
{
|
||||
href: '/integrations/integrations-list',
|
||||
label: 'Integrations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiConnection' in icon ? icon['mdiConnection' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_INTEGRATIONS'
|
||||
},
|
||||
{
|
||||
href: '/roles_catalog/roles_catalog-list',
|
||||
label: 'Roles catalog',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountGroupOutline' in icon ? icon['mdiAccountGroupOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES_CATALOG'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS'
|
||||
label: 'Administration',
|
||||
icon: getIcon('mdiAccountGroup'),
|
||||
permissions: ['READ_USERS', 'READ_ROLES', 'READ_PERMISSIONS', 'READ_API_DOCS'],
|
||||
menu: [
|
||||
{
|
||||
href: '/users/users-list',
|
||||
icon: getIcon('mdiAccountGroup'),
|
||||
label: 'Users',
|
||||
permissions: 'READ_USERS',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
icon: getIcon('mdiShieldAccountVariantOutline'),
|
||||
label: 'Roles',
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
icon: getIcon('mdiShieldAccountOutline'),
|
||||
label: 'Permissions',
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
icon: getIcon('mdiAccountCircle'),
|
||||
label: 'Profile',
|
||||
},
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
icon: getIcon('mdiFileCode'),
|
||||
label: 'Swagger API',
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableAi_tools from '../../components/Ai_tools/TableAi_tools'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,9 +34,9 @@ const Ai_toolsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'ToolName', title: 'name'},{label: 'SecurityPostureSummary', title: 'security_posture_summary'},{label: 'Subprocessors', title: 'subprocessors'},{label: 'Notes', title: 'notes'},
|
||||
const [filters] = useState([{label: 'Tool name', title: 'name'},{label: 'Security posture', title: 'security_posture_summary'},{label: 'Subprocessors', title: 'subprocessors'},{label: 'Notes', title: 'notes'},
|
||||
|
||||
{label: 'MonthlyCost', title: 'monthly_cost', number: 'true'},
|
||||
{label: 'Monthly cost', title: 'monthly_cost', number: 'true'},
|
||||
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ const Ai_toolsTablesPage = () => {
|
||||
|
||||
|
||||
|
||||
{label: 'ToolType', title: 'tool_type', type: 'enum', options: ['llm_chat','legal_research','contract_review','ediscovery','drafting','internal_llm','workflow_automation','other']},{label: 'DeploymentModel', title: 'deployment_model', type: 'enum', options: ['saas','on_prem','private_cloud','hybrid']},{label: 'ApprovalStatus', title: 'approval_status', type: 'enum', options: ['proposed','in_review','approved','restricted','rejected','retired']},{label: 'SOC2Status', title: 'soc2_status', type: 'enum', options: ['none','type_i','type_ii','in_progress','not_applicable']},{label: 'DataRetentionPolicy', title: 'data_retention_policy', type: 'enum', options: ['no_retention','limited_retention','configurable','unknown']},{label: 'TrainingOnClientDataPolicy', title: 'training_on_client_data_policy', type: 'enum', options: ['no','opt_out','opt_in','unknown']},{label: 'DataResidency', title: 'data_residency', type: 'enum', options: ['us','eu','uk','global','configurable','unknown']},{label: 'DeletionPolicy', title: 'deletion_policy', type: 'enum', options: ['immediate','within_30_days','within_90_days','contractual','unknown']},
|
||||
{label: 'Tool type', title: 'tool_type', type: 'enum', options: ['llm_chat','legal_research','contract_review','ediscovery','drafting','internal_llm','workflow_automation','other']},{label: 'Deployment model', title: 'deployment_model', type: 'enum', options: ['saas','on_prem','private_cloud','hybrid']},{label: 'Approval status', title: 'approval_status', type: 'enum', options: ['proposed','in_review','approved','restricted','rejected','retired']},{label: 'SOC 2 status', title: 'soc2_status', type: 'enum', options: ['none','type_i','type_ii','in_progress','not_applicable']},{label: 'Data retention', title: 'data_retention_policy', type: 'enum', options: ['no_retention','limited_retention','configurable','unknown']},{label: 'Client-data training', title: 'training_on_client_data_policy', type: 'enum', options: ['no','opt_out','opt_in','unknown']},{label: 'Data residency', title: 'data_residency', type: 'enum', options: ['us','eu','uk','global','configurable','unknown']},{label: 'Deletion policy', title: 'deletion_policy', type: 'enum', options: ['immediate','within_30_days','within_90_days','contractual','unknown']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_AI_TOOLS');
|
||||
@ -90,27 +90,34 @@ const Ai_toolsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Ai_tools')}</title>
|
||||
<title>{getPageTitle('AI tool registry')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Ai_tools" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="AI tool registry" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/ai_tools/ai_tools-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Approved model layer'
|
||||
title='Catalog AI tools by deployment, data policy, security posture, and approval status'
|
||||
description='Give attorneys a clear source of truth for which tools are proposed, restricted, approved, rejected, or retired before they use them with client material.'
|
||||
metrics={[
|
||||
{ label: 'Security posture', value: 'SOC 2 + retention', helper: 'Capture the exact controls that matter for legal data.' },
|
||||
{ label: 'Model access', value: 'Approval status', helper: 'Teams see which tools are allowed and under what limits.' },
|
||||
{ label: 'Cost view', value: 'Monthly spend', helper: 'Governance and ROI can be discussed in one place.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/ai_tools/ai_tools-new'} color='info' label='New AI tool'/>}
|
||||
<BaseButton href={'/governance-workbench'} color='whiteDark' label='Governance workbench'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getAi_toolsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getAi_toolsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -120,7 +127,7 @@ const Ai_toolsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableAi_tools
|
||||
|
||||
@ -2,15 +2,14 @@ import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableAi_use_cases from '../../components/Ai_use_cases/TableAi_use_cases'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,33 +33,33 @@ const Ai_use_casesTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'UseCaseTitle', title: 'title'},{label: 'Description', title: 'description'},{label: 'BusinessGoal', title: 'business_goal'},{label: 'ReviewNotes', title: 'review_notes'},
|
||||
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Description', title: 'description'},{label: 'Business goal', title: 'business_goal'},{label: 'Review notes', title: 'review_notes'},
|
||||
|
||||
{label: 'ExpectedHoursSaved', title: 'expected_hours_saved', number: 'true'},
|
||||
{label: 'SubmittedAt', title: 'submitted_at', date: 'true'},{label: 'ApprovedAt', title: 'approved_at', date: 'true'},
|
||||
{label: 'Expected hours saved', title: 'expected_hours_saved', number: 'true'},
|
||||
{label: 'Submitted at', title: 'submitted_at', date: 'true'},{label: 'Approved at', title: 'approved_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Owner', title: 'owner'},
|
||||
|
||||
|
||||
|
||||
{label: 'PracticeGroup', title: 'practice_group'},
|
||||
{label: 'Practice group', title: 'practice_group'},
|
||||
|
||||
|
||||
|
||||
{label: 'MatterType', title: 'matter_type'},
|
||||
{label: 'Matter type', title: 'matter_type'},
|
||||
|
||||
|
||||
|
||||
{label: 'DataClassification', title: 'data_classification'},
|
||||
{label: 'Data classification', title: 'data_classification'},
|
||||
|
||||
|
||||
|
||||
{label: 'IntendedAITool', title: 'intended_tool'},
|
||||
{label: 'Intended AI tool', title: 'intended_tool'},
|
||||
|
||||
|
||||
|
||||
{label: 'Status', title: 'status', type: 'enum', options: ['draft','submitted','risk_review','security_review','ethics_review','approved','rejected','needs_changes','retired']},{label: 'RiskLevel', title: 'risk_level', type: 'enum', options: ['low','medium','high','critical']},
|
||||
{label: 'Status', title: 'status', type: 'enum', options: ['draft','submitted','risk_review','security_review','ethics_review','approved','rejected','needs_changes','retired']},{label: 'Risk level', title: 'risk_level', type: 'enum', options: ['low','medium','high','critical']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_AI_USE_CASES');
|
||||
@ -106,27 +105,34 @@ const Ai_use_casesTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Ai_use_cases')}</title>
|
||||
<title>{getPageTitle('AI request register')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Ai_use_cases" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="AI request register" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/ai_use_cases/ai_use_cases-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='AI governance intake'
|
||||
title='Register every proposed AI workflow before it touches client work'
|
||||
description='Capture the business goal, data sensitivity, intended tool, risk level, approval status, and expected ROI for each legal AI use case.'
|
||||
metrics={[
|
||||
{ label: 'Control point', value: 'Use-case intake', helper: 'Every request starts with risk context and owner accountability.' },
|
||||
{ label: 'Review model', value: 'Human approval', helper: 'Partner, GC, security, and ethics review stay visible.' },
|
||||
{ label: 'Evidence', value: 'ROI tracked', helper: 'Hours saved, notes, and approvals become an audit trail.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/ai_use_cases/ai_use_cases-new'} color='info' label='New AI intake'/>}
|
||||
<BaseButton href={'/governance-workbench'} color='whiteDark' label='Governance workbench'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getAi_use_casesCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getAi_use_casesCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -136,11 +142,9 @@ const Ai_use_casesTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/ai_use_cases/ai_use_cases-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton href={'/ai_use_cases/ai_use_cases-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<TableAi_use_cases
|
||||
filterItems={filterItems}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableApproval_steps from '../../components/Approval_steps/TableApproval_steps'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -35,20 +35,20 @@ const Approval_stepsTablesPage = () => {
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Comments', title: 'comments'},
|
||||
{label: 'StepOrder', title: 'step_order', number: 'true'},
|
||||
{label: 'Step order', title: 'step_order', number: 'true'},
|
||||
|
||||
{label: 'AssignedAt', title: 'assigned_at', date: 'true'},{label: 'DecidedAt', title: 'decided_at', date: 'true'},
|
||||
{label: 'Assigned at', title: 'assigned_at', date: 'true'},{label: 'Decided at', title: 'decided_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'AIUseCase', title: 'use_case'},
|
||||
{label: 'AI request', title: 'use_case'},
|
||||
|
||||
|
||||
|
||||
{label: 'AssignedReviewer', title: 'assigned_reviewer'},
|
||||
{label: 'Assigned reviewer', title: 'assigned_reviewer'},
|
||||
|
||||
|
||||
|
||||
{label: 'StepType', title: 'step_type', type: 'enum', options: ['partner','general_counsel','it_security','ethics_risk']},{label: 'Decision', title: 'decision', type: 'enum', options: ['pending','approved','rejected','needs_changes']},
|
||||
{label: 'Review type', title: 'step_type', type: 'enum', options: ['partner','general_counsel','it_security','ethics_risk']},{label: 'Decision', title: 'decision', type: 'enum', options: ['pending','approved','rejected','needs_changes']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_APPROVAL_STEPS');
|
||||
@ -94,27 +94,34 @@ const Approval_stepsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Approval_steps')}</title>
|
||||
<title>{getPageTitle('Approval queue')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Approval_steps" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Approval queue" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/approval_steps/approval_steps-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Decision record'
|
||||
title='Route legal AI requests through accountable review'
|
||||
description='Track who reviewed each AI workflow, what decision was made, when it happened, and which comments need to be preserved for audit readiness.'
|
||||
metrics={[
|
||||
{ label: 'Approvers', value: 'Partner + GC', helper: 'Legal responsibility stays attached to every AI decision.' },
|
||||
{ label: 'Security', value: 'IT review', helper: 'Vendor, data, and access questions move through one queue.' },
|
||||
{ label: 'Outcome', value: 'Decision log', helper: 'Approvals, rejections, and changes are easy to defend later.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/approval_steps/approval_steps-new'} color='info' label='New review step'/>}
|
||||
<BaseButton href={'/governance-workbench'} color='whiteDark' label='Governance workbench'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getApproval_stepsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getApproval_stepsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -124,7 +131,7 @@ const Approval_stepsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableApproval_steps
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableChecklist_items from '../../components/Checklist_items/TableChecklist_items'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -90,27 +90,34 @@ const Checklist_itemsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Checklist_items')}</title>
|
||||
<title>{getPageTitle('Checklist items')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Checklist_items" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Checklist items" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/checklist_items/checklist_items-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Human review control'
|
||||
title='Operationalize human-review checklists before AI output leaves the firm'
|
||||
description='Define the exact review steps attorneys must complete before relying on AI output, filing work product, or sending client-facing material.'
|
||||
metrics={[
|
||||
{ label: 'Review step', value: 'Checklist item', helper: 'Each item is a concrete control, not a vague policy statement.' },
|
||||
{ label: 'Responsibility', value: 'Required flags', helper: 'Mandatory checks stay visible across review workflows.' },
|
||||
{ label: 'Audit value', value: 'Ordered evidence', helper: 'Review order creates a defensible sequence of human oversight.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/checklist_items/checklist_items-new'} color='info' label='New checklist item'/>}
|
||||
<BaseButton href={'/human_review_checklists/human_review_checklists-list'} color='whiteDark' label='Review checklists'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getChecklist_itemsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getChecklist_itemsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -120,11 +127,9 @@ const Checklist_itemsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/checklist_items/checklist_items-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton href={'/checklist_items/checklist_items-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableChecklist_items
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableChecklist_items from '../../components/Checklist_items/TableChecklist_items'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -90,27 +90,33 @@ const Checklist_itemsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Checklist_items')}</title>
|
||||
<title>{getPageTitle('Checklist items table')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Checklist_items" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Checklist items table" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/checklist_items/checklist_items-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Human review control'
|
||||
title='Checklist items as an editable governance table'
|
||||
description='Use table view for bulk review of required steps, item order, and the checklist each control belongs to.'
|
||||
metrics={[
|
||||
{ label: 'View mode', value: 'Table', helper: 'Best for scanning, sorting, and editing several controls quickly.' },
|
||||
{ label: 'Control type', value: 'Human review', helper: 'Each row represents a review action before AI output is trusted.' },
|
||||
{ label: 'Audit value', value: 'Ordered items', helper: 'Order and required flags make oversight repeatable.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/checklist_items/checklist_items-new'} color='info' label='New checklist item'/>}
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getChecklist_itemsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getChecklist_itemsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -118,13 +124,9 @@ const Checklist_itemsTablesPage = () => {
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
|
||||
<Link href={'/checklist_items/checklist_items-list'}>
|
||||
Back to <span className='capitalize'>list</span>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</CardBox>
|
||||
<BaseButton href={'/checklist_items/checklist_items-list'} color='whiteDark' label='Card view'/>
|
||||
</LegalOpsPageIntro>
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableChecklist_items
|
||||
filterItems={filterItems}
|
||||
|
||||
606
frontend/src/pages/governance-workbench.tsx
Normal file
606
frontend/src/pages/governance-workbench.tsx
Normal file
@ -0,0 +1,606 @@
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiArrowRight,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiClipboardPlusOutline,
|
||||
mdiClipboardTextOutline,
|
||||
mdiClockOutline,
|
||||
mdiFactory,
|
||||
mdiFileDocumentOutline,
|
||||
mdiOpenInNew,
|
||||
mdiRobot,
|
||||
mdiSchoolOutline,
|
||||
mdiShieldCheckOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import NotificationBar from '../components/NotificationBar';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import {
|
||||
approvalStepMeta,
|
||||
formatNumber,
|
||||
getDecisionBadge,
|
||||
getRiskBadge,
|
||||
getStatusBadge,
|
||||
} from '../helpers/legalAiFormatting';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type AiUseCaseRecord = {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
risk_level?: string;
|
||||
expected_hours_saved?: number | string;
|
||||
updatedAt?: string;
|
||||
practice_group?: { name?: string };
|
||||
intended_tool?: { name?: string };
|
||||
owner?: { firstName?: string; lastName?: string };
|
||||
};
|
||||
|
||||
type ApprovalStepRecord = {
|
||||
id: string;
|
||||
step_type?: string;
|
||||
step_order?: number;
|
||||
assigned_at?: string;
|
||||
decision?: string;
|
||||
use_case?: AiUseCaseRecord;
|
||||
assigned_reviewer?: { firstName?: string; lastName?: string };
|
||||
};
|
||||
|
||||
type RiskAssessmentRecord = {
|
||||
id: string;
|
||||
assessment_status?: string;
|
||||
overall_score?: number;
|
||||
vendor?: { name?: string };
|
||||
tool?: { name?: string };
|
||||
};
|
||||
|
||||
const ACTIVE_REVIEW_STATUSES = new Set([
|
||||
'submitted',
|
||||
'risk_review',
|
||||
'security_review',
|
||||
'ethics_review',
|
||||
]);
|
||||
|
||||
const PIPELINE_COLUMNS = [
|
||||
{
|
||||
key: 'drafts',
|
||||
title: 'Drafts & revisions',
|
||||
description: 'Use cases being drafted or updated before resubmission.',
|
||||
},
|
||||
{
|
||||
key: 'inReview',
|
||||
title: 'In active review',
|
||||
description: 'Requests currently moving through partner, GC, security, or ethics review.',
|
||||
},
|
||||
{
|
||||
key: 'decided',
|
||||
title: 'Approved & closed',
|
||||
description: 'Approved, rejected, or retired workflows with a final outcome.',
|
||||
},
|
||||
];
|
||||
|
||||
function extractErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data || error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Something went wrong while loading the governance workbench.';
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return 'Recently updated';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatPerson(person?: { firstName?: string; lastName?: string } | null) {
|
||||
const parts = [person?.firstName, person?.lastName].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : 'Unassigned';
|
||||
}
|
||||
|
||||
const GovernanceWorkbench = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [aiUseCases, setAiUseCases] = useState<AiUseCaseRecord[]>([]);
|
||||
const [approvalSteps, setApprovalSteps] = useState<ApprovalStepRecord[]>([]);
|
||||
const [riskAssessments, setRiskAssessments] = useState<RiskAssessmentRecord[]>([]);
|
||||
const [riskAssessmentsCount, setRiskAssessmentsCount] = useState(0);
|
||||
const [policiesCount, setPoliciesCount] = useState(0);
|
||||
const [trainingCoursesCount, setTrainingCoursesCount] = useState(0);
|
||||
const [checklistsCount, setChecklistsCount] = useState(0);
|
||||
const [aiToolsCount, setAiToolsCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadWorkbench = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const canReadApprovalSteps = hasPermission(currentUser, 'READ_APPROVAL_STEPS');
|
||||
const canReadRiskAssessments = hasPermission(currentUser, 'READ_VENDOR_RISK_ASSESSMENTS');
|
||||
const canReadPolicies = hasPermission(currentUser, 'READ_POLICIES');
|
||||
const canReadTraining = hasPermission(currentUser, 'READ_TRAINING_COURSES');
|
||||
const canReadChecklists = hasPermission(currentUser, 'READ_HUMAN_REVIEW_CHECKLISTS');
|
||||
const canReadAiTools = hasPermission(currentUser, 'READ_AI_TOOLS');
|
||||
|
||||
const [
|
||||
aiUseCasesResponse,
|
||||
approvalStepsResponse,
|
||||
riskAssessmentsResponse,
|
||||
policiesCountResponse,
|
||||
trainingCountResponse,
|
||||
checklistsCountResponse,
|
||||
aiToolsCountResponse,
|
||||
] = await Promise.all([
|
||||
axios.get('/ai_use_cases?limit=24&page=0'),
|
||||
canReadApprovalSteps
|
||||
? axios.get('/approval_steps?limit=100&page=0&decision=pending')
|
||||
: Promise.resolve({ data: { rows: [] } }),
|
||||
canReadRiskAssessments
|
||||
? axios.get('/vendor_risk_assessments?limit=6&page=0')
|
||||
: Promise.resolve({ data: { rows: [] } }),
|
||||
canReadPolicies ? axios.get('/policies/count') : Promise.resolve({ data: { count: 0 } }),
|
||||
canReadTraining ? axios.get('/training_courses/count') : Promise.resolve({ data: { count: 0 } }),
|
||||
canReadChecklists
|
||||
? axios.get('/human_review_checklists/count')
|
||||
: Promise.resolve({ data: { count: 0 } }),
|
||||
canReadAiTools ? axios.get('/ai_tools/count') : Promise.resolve({ data: { count: 0 } }),
|
||||
]);
|
||||
|
||||
setAiUseCases(aiUseCasesResponse.data?.rows || []);
|
||||
setApprovalSteps(approvalStepsResponse.data?.rows || []);
|
||||
setRiskAssessments(riskAssessmentsResponse.data?.rows || []);
|
||||
setRiskAssessmentsCount(Number(riskAssessmentsResponse.data?.count || 0));
|
||||
setPoliciesCount(Number(policiesCountResponse.data?.count || 0));
|
||||
setTrainingCoursesCount(Number(trainingCountResponse.data?.count || 0));
|
||||
setChecklistsCount(Number(checklistsCountResponse.data?.count || 0));
|
||||
setAiToolsCount(Number(aiToolsCountResponse.data?.count || 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load governance workbench:', error);
|
||||
setErrorMessage(extractErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWorkbench();
|
||||
}, [currentUser]);
|
||||
|
||||
const pipeline = useMemo(() => {
|
||||
return {
|
||||
drafts: aiUseCases.filter((useCase) => ['draft', 'needs_changes'].includes(useCase.status || 'draft')),
|
||||
inReview: aiUseCases.filter((useCase) => ACTIVE_REVIEW_STATUSES.has(useCase.status || '')),
|
||||
decided: aiUseCases.filter((useCase) => ['approved', 'rejected', 'retired'].includes(useCase.status || '')),
|
||||
};
|
||||
}, [aiUseCases]);
|
||||
|
||||
const activeReviewQueue = useMemo(() => {
|
||||
const queueByUseCase = new Map<string, ApprovalStepRecord>();
|
||||
|
||||
[...approvalSteps]
|
||||
.filter((step) => ACTIVE_REVIEW_STATUSES.has(step.use_case?.status || ''))
|
||||
.sort((first, second) => {
|
||||
const firstOrder = first.step_order || 0;
|
||||
const secondOrder = second.step_order || 0;
|
||||
|
||||
if (firstOrder !== secondOrder) {
|
||||
return firstOrder - secondOrder;
|
||||
}
|
||||
|
||||
return new Date(first.assigned_at || 0).getTime() - new Date(second.assigned_at || 0).getTime();
|
||||
})
|
||||
.forEach((step) => {
|
||||
const useCaseId = step.use_case?.id;
|
||||
|
||||
if (useCaseId && !queueByUseCase.has(useCaseId)) {
|
||||
queueByUseCase.set(useCaseId, step);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(queueByUseCase.values()).slice(0, 6);
|
||||
}, [approvalSteps]);
|
||||
|
||||
const expectedHoursSaved = useMemo(() => {
|
||||
return aiUseCases.reduce((total, useCase) => total + Number(useCase.expected_hours_saved || 0), 0);
|
||||
}, [aiUseCases]);
|
||||
|
||||
const approvedUseCasesCount = useMemo(() => {
|
||||
return aiUseCases.filter((useCase) => useCase.status === 'approved').length;
|
||||
}, [aiUseCases]);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Live use cases',
|
||||
value: aiUseCases.length,
|
||||
helper: 'Governance requests in the register',
|
||||
icon: mdiClipboardTextOutline,
|
||||
},
|
||||
{
|
||||
label: 'Active reviews',
|
||||
value: activeReviewQueue.length,
|
||||
helper: 'Items currently waiting on a reviewer',
|
||||
icon: mdiClockOutline,
|
||||
},
|
||||
{
|
||||
label: 'Approved workflows',
|
||||
value: approvedUseCasesCount,
|
||||
helper: 'Use cases cleared for production use',
|
||||
icon: mdiCheckCircleOutline,
|
||||
},
|
||||
{
|
||||
label: 'Expected hours saved',
|
||||
value: formatNumber(expectedHoursSaved),
|
||||
helper: 'Projected time saved across the register',
|
||||
icon: mdiChartTimelineVariant,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Governance Workbench')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiShieldCheckOutline} title='Governance workbench' main>
|
||||
<BaseButton color='info' label='AI use case register' href='/ai_use_cases/ai_use_cases-list' />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{errorMessage && (
|
||||
<NotificationBar color='danger' icon={mdiAlertCircleOutline}>
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
<CardBox className='mb-6 overflow-hidden border-0 bg-[#0F172A] text-white'>
|
||||
<div className='grid gap-8 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.8fr)]'>
|
||||
<div className='space-y-6 p-2'>
|
||||
<div className='inline-flex items-center rounded-full border border-white/15 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200'>
|
||||
Single-organization legal AI governance
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<h2 className='max-w-3xl text-3xl font-semibold tracking-tight text-white xl:text-4xl'>
|
||||
Move AI requests from intake to approval with a governance trail your firm can defend.
|
||||
</h2>
|
||||
<p className='max-w-2xl text-base leading-7 text-slate-300'>
|
||||
Launch new legal AI use cases, surface the next reviewer, keep guardrails visible, and track
|
||||
adoption without turning the platform into a legal research bot.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton color='info' label='Launch new intake' icon={mdiClipboardPlusOutline} href='/ai_use_cases/ai_use_cases-new' />
|
||||
<BaseButton color='whiteDark' label='Open approval queue' icon={mdiOpenInNew} href='/approval_steps/approval_steps-list' />
|
||||
<BaseButton color='whiteDark' outline label='Vendor assessments' href='/vendor_risk_assessments/vendor_risk_assessments-list' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||||
{[
|
||||
{
|
||||
title: 'Review path',
|
||||
body: 'Partner → GC → IT / Security → Ethics / Risk',
|
||||
},
|
||||
{
|
||||
title: 'Default intake controls',
|
||||
body: 'Owner, matter type, data classification, intended tool, business goal, ROI estimate',
|
||||
},
|
||||
{
|
||||
title: 'Policy coverage',
|
||||
body: `${policiesCount} policies, ${checklistsCount} checklists, ${trainingCoursesCount} training courses`,
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className='rounded-xl border border-white/10 bg-black/10 p-4'>
|
||||
<div className='text-sm font-semibold text-white'>{item.title}</div>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-300'>{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className='mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{metrics.map((metric) => (
|
||||
<CardBox key={metric.label} className='border border-[#DDD5C7] bg-white'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium text-slate-500'>{metric.label}</p>
|
||||
<p className='mt-3 text-3xl font-semibold text-slate-900'>{metric.value}</p>
|
||||
<p className='mt-2 text-sm text-slate-500'>{metric.helper}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-[#F1E7C7] p-3 text-[#7A5B13]'>
|
||||
<BaseIcon path={metric.icon} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mb-6 grid gap-6 min-[1800px]:grid-cols-[minmax(0,1.55fr)_minmax(360px,0.9fr)]'>
|
||||
<CardBox className='border border-[#DDD5C7] bg-white p-6'>
|
||||
<div className='mb-7 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-900'>Workflow pipeline</h3>
|
||||
<p className='mt-2 max-w-3xl text-sm leading-6 text-slate-500'>
|
||||
A thin but complete operational view of AI requests moving through draft, review, and final
|
||||
decision states.
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0'>
|
||||
<BaseButton color='info' small label='View register' href='/ai_use_cases/ai_use_cases-list' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-5 xl:grid-cols-3'>
|
||||
{PIPELINE_COLUMNS.map((column) => {
|
||||
const records = pipeline[column.key as keyof typeof pipeline].slice(0, 4);
|
||||
|
||||
return (
|
||||
<div key={column.key} className='rounded-2xl border border-slate-200 bg-slate-50/80 p-5'>
|
||||
<div className='mb-5 border-b border-slate-200 pb-4'>
|
||||
<h4 className='text-sm font-semibold uppercase tracking-[0.12em] text-slate-700'>
|
||||
{column.title}
|
||||
</h4>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>{column.description}</p>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{records.length ? (
|
||||
records.map((useCase) => {
|
||||
const statusBadge = getStatusBadge(useCase.status);
|
||||
const riskBadge = getRiskBadge(useCase.risk_level);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={useCase.id}
|
||||
className='rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-200/50'
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className={statusBadge.className}>{statusBadge.label}</span>
|
||||
<span className={riskBadge.className}>{riskBadge.label} risk</span>
|
||||
</div>
|
||||
<h5 className='mt-4 text-base font-semibold leading-6 text-slate-900'>
|
||||
{useCase.title || 'Untitled AI use case'}
|
||||
</h5>
|
||||
<div className='mt-4 grid gap-3 text-sm text-slate-500 sm:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-slate-400'>
|
||||
Practice
|
||||
</p>
|
||||
<p className='mt-1 leading-5 text-slate-700'>
|
||||
{useCase.practice_group?.name || 'Pending'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-slate-400'>
|
||||
Tool
|
||||
</p>
|
||||
<p className='mt-1 leading-5 text-slate-700'>
|
||||
{useCase.intended_tool?.name || 'Pending'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-slate-400'>
|
||||
Owner
|
||||
</p>
|
||||
<p className='mt-1 leading-5 text-slate-700'>{formatPerson(useCase.owner)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.12em] text-slate-400'>
|
||||
ROI
|
||||
</p>
|
||||
<p className='mt-1 leading-5 text-slate-700'>
|
||||
{formatNumber(useCase.expected_hours_saved)} hrs saved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-5'>
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
small
|
||||
label='Open detail'
|
||||
icon={mdiArrowRight}
|
||||
href={`/ai_use_cases/ai_use_cases-view/?id=${useCase.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-white px-5 py-10 text-center text-sm text-slate-500'>
|
||||
No items in this lane yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border border-[#DDD5C7] bg-white'>
|
||||
<div className='mb-4 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-900'>Active review queue</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Next review step per live request.</p>
|
||||
</div>
|
||||
<BaseButton color='whiteDark' small label='All approvals' href='/approval_steps/approval_steps-list' />
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{activeReviewQueue.length ? (
|
||||
activeReviewQueue.map((step) => {
|
||||
const stepDetails = approvalStepMeta[step.step_type as keyof typeof approvalStepMeta];
|
||||
const statusBadge = getStatusBadge(step.use_case?.status);
|
||||
|
||||
return (
|
||||
<div key={step.id} className='rounded-2xl border border-slate-200 bg-slate-50 p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-500'>
|
||||
{stepDetails?.label || 'Review step'}
|
||||
</p>
|
||||
<h4 className='mt-2 text-base font-semibold text-slate-900'>
|
||||
{step.use_case?.title || 'Untitled AI use case'}
|
||||
</h4>
|
||||
</div>
|
||||
<span className={statusBadge.className}>{statusBadge.label}</span>
|
||||
</div>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-500'>{stepDetails?.description}</p>
|
||||
<div className='mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500'>
|
||||
<span>{formatPerson(step.assigned_reviewer)}</span>
|
||||
<span>Assigned {formatDate(step.assigned_at)}</span>
|
||||
</div>
|
||||
<div className='mt-4'>
|
||||
<BaseButton
|
||||
color='info'
|
||||
small
|
||||
label='Open review'
|
||||
href={`/ai_use_cases/ai_use_cases-view/?id=${step.use_case?.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500'>
|
||||
No active reviews. Submit a draft AI use case to kick off the approval chain.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border border-[#DDD5C7] bg-white'>
|
||||
<div className='mb-4 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-900'>Governance coverage</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Supporting controls that make approvals operational.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
{[
|
||||
{ label: 'AI tools in registry', value: aiToolsCount, icon: mdiRobot },
|
||||
{ label: 'Vendor assessments', value: riskAssessmentsCount, icon: mdiFactory },
|
||||
{ label: 'Policies & guardrails', value: policiesCount, icon: mdiFileDocumentOutline },
|
||||
{ label: 'Training courses', value: trainingCoursesCount, icon: mdiSchoolOutline },
|
||||
].map((item) => (
|
||||
<div key={item.label} className='flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='rounded-xl bg-white p-2 text-[#7A5B13]'>
|
||||
<BaseIcon path={item.icon} size={22} />
|
||||
</div>
|
||||
<span className='text-sm font-medium text-slate-700'>{item.label}</span>
|
||||
</div>
|
||||
<span className='text-base font-semibold text-slate-900'>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.2fr_1fr]'>
|
||||
<CardBox className='border border-[#DDD5C7] bg-white'>
|
||||
<div className='mb-4 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-900'>Vendor assessment snapshot</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Latest vendor due-diligence records tied to AI adoption.</p>
|
||||
</div>
|
||||
<BaseButton color='whiteDark' small label='Open registry' href='/vendors/vendors-list' />
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{riskAssessments.length ? (
|
||||
riskAssessments.slice(0, 3).map((assessment) => {
|
||||
const decisionBadge = getDecisionBadge(
|
||||
assessment.assessment_status === 'approved'
|
||||
? 'approved'
|
||||
: assessment.assessment_status === 'rejected'
|
||||
? 'rejected'
|
||||
: assessment.assessment_status === 'needs_changes'
|
||||
? 'needs_changes'
|
||||
: 'pending',
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={assessment.id} className='rounded-2xl border border-slate-200 bg-slate-50 p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-slate-900'>
|
||||
{assessment.vendor?.name || 'Vendor pending'}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>{assessment.tool?.name || 'Tool not linked yet'}</p>
|
||||
</div>
|
||||
<span className={decisionBadge.className}>{decisionBadge.label}</span>
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-between text-sm text-slate-500'>
|
||||
<span>Overall score</span>
|
||||
<span className='font-semibold text-slate-900'>{formatNumber(assessment.overall_score)}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500'>
|
||||
No vendor assessment records yet. Add one from the vendor risk assessment module.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border border-[#DDD5C7] bg-[#FBF8F1]'>
|
||||
<div className='mb-4'>
|
||||
<h3 className='text-xl font-semibold text-slate-900'>First-iteration workflow</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>A thin but working path from intake to reviewer action.</p>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{[
|
||||
'Create an AI use case draft with matter context, tool, data classification, and expected ROI.',
|
||||
'Submit the draft to automatically create partner, GC, security, and ethics review steps.',
|
||||
'Open the detail page to approve, reject, or request changes with a visible audit trail.',
|
||||
'Use the register and workbench to monitor throughput and see where reviews are waiting.',
|
||||
].map((item, index) => (
|
||||
<div key={item} className='flex gap-3 rounded-2xl border border-slate-200 bg-white p-4'>
|
||||
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#0F172A] text-sm font-semibold text-white'>
|
||||
{index + 1}
|
||||
</div>
|
||||
<p className='text-sm leading-6 text-slate-600'>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className='mt-6 rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-8 text-center text-sm text-slate-500'>
|
||||
Loading governance metrics and queue data…
|
||||
</div>
|
||||
)}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GovernanceWorkbench.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission='READ_AI_USE_CASES'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default GovernanceWorkbench;
|
||||
@ -1,166 +1,527 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
mdiAlertOctagonOutline,
|
||||
mdiArrowRight,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiClipboardCheckOutline,
|
||||
mdiClipboardTextOutline,
|
||||
mdiFileDocumentOutline,
|
||||
mdiLockCheckOutline,
|
||||
mdiShieldCheckOutline,
|
||||
mdiShieldSearch,
|
||||
mdiTimelineTextOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const controlStats = [
|
||||
{ value: '1', label: 'Control layer across every AI tool' },
|
||||
{ value: '4', label: 'Approvals before high-risk use' },
|
||||
{ value: '100%', label: 'Human review evidence captured' },
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const controlPlaneMetrics = [
|
||||
{ label: 'Governance score', value: '82', tone: 'text-emerald-300' },
|
||||
{ label: 'Open reviews', value: '12', tone: 'text-[#D8B75E]' },
|
||||
{ label: 'Blocked actions', value: '4', tone: 'text-rose-300' },
|
||||
];
|
||||
|
||||
const title = 'Legal AI Governance Hub'
|
||||
const reviewQueue = [
|
||||
{
|
||||
title: 'Deposition transcript summary',
|
||||
meta: 'Litigation · privileged client material',
|
||||
status: 'IT / Security',
|
||||
risk: 'Critical',
|
||||
},
|
||||
{
|
||||
title: 'Contract clause comparison',
|
||||
meta: 'Corporate · confidential deal docs',
|
||||
status: 'Ethics / Risk',
|
||||
risk: 'High',
|
||||
},
|
||||
{
|
||||
title: 'Client intake triage',
|
||||
meta: 'Employment · regulated personal data',
|
||||
status: 'GC review',
|
||||
risk: 'High',
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
const vendorSignals = [
|
||||
{ name: 'ChatGPT Enterprise', posture: 'Approved w/ retention limits' },
|
||||
{ name: 'Claude Team', posture: 'Restricted to internal matters' },
|
||||
{ name: 'Lexis AI Research', posture: 'Citation verification required' },
|
||||
];
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const auditEvents = [
|
||||
'Client notice requirement applied',
|
||||
'Human review checklist attached',
|
||||
'Vendor DPA evidence linked',
|
||||
'ROI baseline recorded',
|
||||
];
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const governanceModules = [
|
||||
{
|
||||
title: 'AI request intake',
|
||||
text: 'Capture the workflow, owner, practice group, matter type, intended AI tool, business goal, and ROI estimate before anything goes live.',
|
||||
icon: mdiClipboardTextOutline,
|
||||
},
|
||||
{
|
||||
title: 'Approval queue',
|
||||
text: 'Route each request through partner, general counsel, IT/security, and ethics/risk review with comments and decision history.',
|
||||
icon: mdiClipboardCheckOutline,
|
||||
},
|
||||
{
|
||||
title: 'Vendor risk',
|
||||
text: 'Track vendor posture, data retention, client-data training policy, audit logs, deletion rules, evidence, and mitigations.',
|
||||
icon: mdiShieldSearch,
|
||||
},
|
||||
{
|
||||
title: 'Usage & audit',
|
||||
text: 'Keep a defensible record of approved workflows, human review requirements, exceptions, actual hours saved, and policy outcomes.',
|
||||
icon: mdiTimelineTextOutline,
|
||||
},
|
||||
];
|
||||
|
||||
const thesisCards = [
|
||||
{
|
||||
title: 'AI sprawl creates governance pain',
|
||||
text: 'Every new model, assistant, Word add-in, and vendor feature creates questions around privilege, data retention, client consent, review, and accountability.',
|
||||
icon: mdiAlertOctagonOutline,
|
||||
},
|
||||
{
|
||||
title: 'Governance is the buyer wedge',
|
||||
text: 'The buyer is not one attorney testing prompts. It is firm leadership, general counsel, legal ops, IT/security, and risk committees rolling AI out safely.',
|
||||
icon: mdiShieldCheckOutline,
|
||||
},
|
||||
{
|
||||
title: 'Workflow evidence compounds',
|
||||
text: 'Approvals, exceptions, vendor decisions, training, usage, and ROI become the system of record for how the organization adopts AI.',
|
||||
icon: mdiChartTimelineVariant,
|
||||
},
|
||||
];
|
||||
|
||||
const contextImages = [
|
||||
{
|
||||
src: 'https://images.pexels.com/photos/5668780/pexels-photo-5668780.jpeg?auto=compress&cs=tinysrgb&w=1200',
|
||||
title: 'AI rollout review',
|
||||
text: 'Leadership, practice groups, security, and legal ops need one place to decide which AI workflows are safe enough to deploy.',
|
||||
},
|
||||
{
|
||||
src: 'https://images.pexels.com/photos/7109288/pexels-photo-7109288.jpeg?auto=compress&cs=tinysrgb&w=1200',
|
||||
title: 'Evidence over enthusiasm',
|
||||
text: 'The product turns AI adoption into measurable operational evidence: approvals, controls, exceptions, and realized value.',
|
||||
},
|
||||
{
|
||||
src: 'https://images.pexels.com/photos/9300737/pexels-photo-9300737.jpeg?auto=compress&cs=tinysrgb&w=1200',
|
||||
title: 'Boardroom-ready governance',
|
||||
text: 'A defensible system of record for the questions managing partners, GCs, insurers, and clients will ask.',
|
||||
},
|
||||
];
|
||||
|
||||
const proofPoints = [
|
||||
'AI tools governed centrally, not adopted ad hoc by each practice group.',
|
||||
'Client and matter sensitivity visible before a workflow reaches production.',
|
||||
'Human review checkpoints attached to filings, client sends, reliance, and billing.',
|
||||
'ROI tracked as operational evidence, not vague AI enthusiasm.',
|
||||
];
|
||||
|
||||
const workflowSteps = [
|
||||
{
|
||||
title: '1. Submit',
|
||||
text: 'A lawyer or legal ops owner submits an AI workflow request with matter context, data sensitivity, and expected value.',
|
||||
},
|
||||
{
|
||||
title: '2. Classify',
|
||||
text: 'The request is reviewed against tool posture, vendor risk, data rules, client notice, and required human oversight.',
|
||||
},
|
||||
{
|
||||
title: '3. Approve',
|
||||
text: 'Partner, GC, security, and ethics reviewers approve, reject, or request changes with a visible trail.',
|
||||
},
|
||||
{
|
||||
title: '4. Monitor',
|
||||
text: 'Approved workflows move into usage tracking, exception review, policy coverage, and ROI reporting.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Legal AI Governance Hub')}</title>
|
||||
<meta
|
||||
name='description'
|
||||
content='A legal AI governance command center for AI requests, approvals, vendor risk, human review, audit trails, training, and ROI.'
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Legal AI Governance Hub app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className='min-h-screen bg-[#F6F3EC] text-[#111827]'>
|
||||
<header className='border-b border-[#DDD5C7] bg-[#F6F3EC]/95'>
|
||||
<div className='mx-auto flex max-w-7xl items-center justify-between px-5 py-4 lg:px-8'>
|
||||
<Link href='/' className='flex items-center gap-3'>
|
||||
<span className='flex h-9 w-9 items-center justify-center rounded-md bg-[#0E1A2B] text-[#D8B75E]'>
|
||||
<BaseIcon path={mdiShieldCheckOutline} size={21} />
|
||||
</span>
|
||||
<span>
|
||||
<span className='block text-base font-semibold text-[#0E1A2B]'>Legal AI Governance Hub</span>
|
||||
<span className='block text-xs text-[#6F6657]'>AI adoption control plane</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<BaseButton href='/login?next=/governance-workbench' color='whiteDark' label='Login' />
|
||||
<BaseButton
|
||||
href='/login?next=/governance-workbench'
|
||||
color='info'
|
||||
label='Open workspace'
|
||||
icon={mdiArrowRight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className='border-b border-[#DDD5C7] bg-[#F6F3EC]'>
|
||||
<div className='mx-auto grid max-w-7xl gap-10 px-5 py-12 lg:grid-cols-[0.92fr_1.08fr] lg:items-center lg:px-8 lg:py-16'>
|
||||
<div>
|
||||
<div className='mb-6 inline-flex items-center gap-2 rounded-md border border-[#D8B75E]/45 bg-[#FFF8E8] px-3 py-2 text-sm font-semibold text-[#7A5B13]'>
|
||||
<BaseIcon path={mdiLockCheckOutline} size={18} />
|
||||
The missing control layer for legal AI adoption
|
||||
</div>
|
||||
|
||||
<h1 className='max-w-3xl text-5xl font-semibold leading-[1.02] text-[#0E1A2B] lg:text-6xl'>
|
||||
AI governance infrastructure for law firms
|
||||
</h1>
|
||||
<p className='mt-5 max-w-2xl text-xl leading-8 text-[#4B5563]'>
|
||||
Legal teams are adopting Harvey, Copilot, ChatGPT, Claude, Lexis, and internal models. The hard
|
||||
problem is no longer access to AI. It is approval, oversight, vendor risk, audit evidence, and ROI.
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
href='/login?next=/governance-workbench'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
label='Run an AI readiness review'
|
||||
icon={mdiArrowRight}
|
||||
/>
|
||||
<BaseButton href='/login?next=/governance-workbench' color='whiteDark' label='Open control plane' />
|
||||
</div>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className='mt-9 grid gap-3 sm:grid-cols-3'>
|
||||
{controlStats.map((stat) => (
|
||||
<div key={stat.label} className='rounded-lg border border-[#DDD5C7] bg-white px-4 py-4'>
|
||||
<div className='text-3xl font-semibold text-[#0E1A2B]'>{stat.value}</div>
|
||||
<div className='mt-2 text-sm leading-5 text-[#6B7280]'>{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='overflow-hidden rounded-xl border border-[#C9BFAE] bg-[#07111F] shadow-2xl shadow-[#83755E]/20'>
|
||||
<div className='border-b border-white/10 bg-white/[0.03] px-5 py-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-white'>AI Governance Control Plane</div>
|
||||
<div className='mt-1 text-xs text-slate-400'>Firmwide oversight across models, tools, matters, and reviewers</div>
|
||||
</div>
|
||||
<span className='rounded-md border border-emerald-400/25 bg-emerald-400/10 px-3 py-1 text-xs font-semibold text-emerald-200'>
|
||||
Audit packet ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 p-5 lg:grid-cols-[0.9fr_1.1fr]'>
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<div>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Firm risk posture</div>
|
||||
<div className='mt-3 text-5xl font-semibold text-white'>82</div>
|
||||
</div>
|
||||
<div className='pb-1 text-right text-sm text-emerald-200'>Operational<br />but exposed</div>
|
||||
</div>
|
||||
<div className='mt-4 grid grid-cols-10 gap-1'>
|
||||
{Array.from({ length: 30 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-2 rounded-sm ${
|
||||
index < 4
|
||||
? 'bg-rose-400'
|
||||
: index < 12
|
||||
? 'bg-[#D8B75E]'
|
||||
: 'bg-emerald-400'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-3 lg:grid-cols-1'>
|
||||
{controlPlaneMetrics.map((metric) => (
|
||||
<div key={metric.label} className='rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3'>
|
||||
<div className='text-xs text-slate-400'>{metric.label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold ${metric.tone}`}>{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='text-sm font-semibold text-white'>High-value AI requests</div>
|
||||
<div className='text-xs text-[#D8B75E]'>Approval queue</div>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
{reviewQueue.map((request) => (
|
||||
<div key={request.title} className='rounded-md border border-white/10 bg-[#0D1A2C] p-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-white'>{request.title}</div>
|
||||
<div className='mt-1 text-xs text-slate-400'>{request.meta}</div>
|
||||
</div>
|
||||
<span className='rounded bg-rose-400/10 px-2 py-1 text-xs font-semibold text-rose-200'>
|
||||
{request.risk}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-3 flex items-center justify-between text-xs'>
|
||||
<span className='text-slate-400'>Next reviewer</span>
|
||||
<span className='font-semibold text-[#D8B75E]'>{request.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='mb-3 text-sm font-semibold text-white'>Vendor posture</div>
|
||||
<div className='space-y-3'>
|
||||
{vendorSignals.map((vendor) => (
|
||||
<div key={vendor.name}>
|
||||
<div className='text-xs font-semibold text-slate-200'>{vendor.name}</div>
|
||||
<div className='mt-1 text-xs leading-5 text-slate-400'>{vendor.posture}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='mb-3 text-sm font-semibold text-white'>Evidence ledger</div>
|
||||
<div className='space-y-3'>
|
||||
{auditEvents.map((event) => (
|
||||
<div key={event} className='flex gap-2 text-xs leading-5 text-slate-300'>
|
||||
<span className='mt-1 h-2 w-2 shrink-0 rounded-full bg-emerald-400' />
|
||||
<span>{event}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid gap-3 md:grid-cols-3'>
|
||||
{[
|
||||
{ label: 'Category wedge', value: 'Governance before automation', icon: mdiAlertOctagonOutline },
|
||||
{ label: 'Buyer', value: 'Managing partner / GC / Legal ops', icon: mdiShieldCheckOutline },
|
||||
{ label: 'Expansion', value: 'Integrations + custom workflows', icon: mdiChartTimelineVariant },
|
||||
].map((item) => (
|
||||
<div key={item.label} className='rounded-lg border border-[#DDD5C7] bg-white p-4'>
|
||||
<div className='mb-3 flex h-8 w-8 items-center justify-center rounded-md bg-[#F1E7C7] text-[#7A5B13]'>
|
||||
<BaseIcon path={item.icon} size={19} />
|
||||
</div>
|
||||
<div className='text-xs font-semibold uppercase text-[#6F6657]'>{item.label}</div>
|
||||
<div className='mt-1 text-sm font-semibold text-[#0E1A2B]'>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='border-b border-[#DDD5C7] bg-[#101826]'>
|
||||
<div className='mx-auto grid max-w-7xl gap-4 px-5 py-8 lg:grid-cols-3 lg:px-8'>
|
||||
{thesisCards.map((card) => (
|
||||
<div key={card.title} className='rounded-lg border border-white/10 bg-white/[0.04] p-5'>
|
||||
<div className='mb-4 flex h-10 w-10 items-center justify-center rounded-md bg-[#D8B75E]/15 text-[#D8B75E]'>
|
||||
<BaseIcon path={card.icon} size={22} />
|
||||
</div>
|
||||
<h2 className='text-lg font-semibold text-white'>{card.title}</h2>
|
||||
<p className='mt-3 text-sm leading-7 text-slate-300'>{card.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='border-b border-[#DDD5C7] bg-[#F6F3EC]'>
|
||||
<div className='mx-auto max-w-7xl px-5 py-12 lg:px-8'>
|
||||
<div className='mb-8 grid gap-5 lg:grid-cols-[0.8fr_1fr] lg:items-end'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold uppercase text-[#7A5B13]'>Where the budget pain lives</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold text-[#0E1A2B]'>
|
||||
AI adoption has moved from experiments to governance meetings.
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-base leading-7 text-[#4B5563]'>
|
||||
The strongest visual story is not “lawyer with chatbot.” It is the room where AI tools become policy,
|
||||
risk, approvals, training, audit evidence, and integration work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
{contextImages.map((image) => (
|
||||
<div key={image.title} className='overflow-hidden rounded-xl border border-[#DDD5C7] bg-white'>
|
||||
<div className='relative aspect-[16/10] bg-[#0E1A2B]'>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.title}
|
||||
fill
|
||||
sizes='(min-width: 1024px) 33vw, 100vw'
|
||||
className='object-cover'
|
||||
/>
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-[#07111F]/80 via-[#07111F]/15 to-transparent' />
|
||||
<div className='absolute bottom-4 left-4 right-4'>
|
||||
<div className='inline-flex rounded-md bg-[#D8B75E] px-2.5 py-1 text-xs font-semibold text-[#0E1A2B]'>
|
||||
Legal AI governance
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-5'>
|
||||
<h3 className='text-lg font-semibold text-[#0E1A2B]'>{image.title}</h3>
|
||||
<p className='mt-3 text-sm leading-7 text-[#5B6472]'>{image.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='mx-auto max-w-7xl px-5 py-12 lg:px-8'>
|
||||
<div className='mb-8 grid gap-5 lg:grid-cols-[0.75fr_1fr] lg:items-end'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold uppercase text-[#7A5B13]'>Operational surface</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold text-[#0E1A2B]'>
|
||||
The screens a legal AI program actually needs.
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-base leading-7 text-[#4B5563]'>
|
||||
The product should feel like governance infrastructure: dense enough for repeated use, calm enough for
|
||||
lawyers, and explicit about who approved what, under which controls, and why.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{governanceModules.map((module) => (
|
||||
<div key={module.title} className='rounded-lg border border-[#DDD5C7] bg-white p-5'>
|
||||
<div className='mb-5 flex h-11 w-11 items-center justify-center rounded-md bg-[#0E1A2B] text-[#D8B75E]'>
|
||||
<BaseIcon path={module.icon} size={23} />
|
||||
</div>
|
||||
<h3 className='text-lg font-semibold text-[#0E1A2B]'>{module.title}</h3>
|
||||
<p className='mt-3 text-sm leading-7 text-[#5B6472]'>{module.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='border-y border-[#DDD5C7] bg-white'>
|
||||
<div className='mx-auto grid max-w-7xl gap-8 px-5 py-12 lg:grid-cols-[0.9fr_1.1fr] lg:px-8'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold uppercase text-[#7A5B13]'>Governance workflow</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold text-[#0E1A2B]'>
|
||||
From AI idea to defensible approval trail.
|
||||
</h2>
|
||||
<p className='mt-4 text-base leading-8 text-[#4B5563]'>
|
||||
The demo story is simple: bring one risky AI workflow, turn it into a structured request, route it
|
||||
through reviewers, then monitor usage and value after approval.
|
||||
</p>
|
||||
<div className='mt-7 space-y-3'>
|
||||
{proofPoints.map((point) => (
|
||||
<div key={point} className='flex gap-3 rounded-lg border border-[#E5E0D6] bg-[#FBF8F1] px-4 py-3'>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={20} className='mt-0.5 text-emerald-700' />
|
||||
<span className='text-sm leading-6 text-[#374151]'>{point}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#DDD5C7] bg-[#F6F3EC] p-5'>
|
||||
<div className='space-y-4'>
|
||||
{workflowSteps.map((step) => (
|
||||
<div key={step.title} className='rounded-lg border border-[#DDD5C7] bg-white p-5'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[#0E1A2B] text-sm font-semibold text-[#D8B75E]'>
|
||||
{step.title.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-base font-semibold text-[#0E1A2B]'>{step.title}</h3>
|
||||
<p className='mt-2 text-sm leading-7 text-[#5B6472]'>{step.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='mx-auto max-w-7xl px-5 py-12 lg:px-8'>
|
||||
<div className='rounded-lg border border-[#C9BFAE] bg-[#0E1A2B] p-6 text-white lg:p-8'>
|
||||
<div className='grid gap-8 lg:grid-cols-[1fr_0.85fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold uppercase text-[#D8B75E]'>Lead magnet angle</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold'>Free Legal AI Readiness Blueprint</h2>
|
||||
<p className='mt-4 max-w-2xl text-base leading-8 text-slate-300'>
|
||||
Map the top AI workflows, classify risk, choose the right governance path, and identify what needs
|
||||
to be automated or integrated before firmwide rollout.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
{[
|
||||
{ label: 'AI requests', icon: mdiClipboardTextOutline },
|
||||
{ label: 'Policies', icon: mdiFileDocumentOutline },
|
||||
{ label: 'Vendor risk', icon: mdiShieldSearch },
|
||||
{ label: 'Audit & ROI', icon: mdiChartTimelineVariant },
|
||||
].map((item) => (
|
||||
<div key={item.label} className='rounded-lg border border-white/10 bg-white/5 p-4'>
|
||||
<BaseIcon path={item.icon} size={22} className='text-[#D8B75E]' />
|
||||
<div className='mt-3 text-sm font-semibold'>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className='border-t border-[#DDD5C7] bg-[#F6F3EC]'>
|
||||
<div className='mx-auto flex max-w-7xl flex-col gap-3 px-5 py-6 text-sm text-[#6F6657] md:flex-row md:items-center md:justify-between lg:px-8'>
|
||||
<p>© 2026 Legal AI Governance Hub. Built for controlled AI adoption in legal teams.</p>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Link href='/privacy-policy' className='hover:text-[#0E1A2B]'>
|
||||
Privacy policy
|
||||
</Link>
|
||||
<Link href='/terms-of-use' className='hover:text-[#0E1A2B]'>
|
||||
Terms of use
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ import { getPageTitle } from '../../config'
|
||||
import TableIntegrations from '../../components/Integrations/TableIntegrations'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,7 +33,7 @@ const IntegrationsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'IntegrationName', title: 'name'},{label: 'ConfigurationNotes', title: 'configuration_notes'},
|
||||
const [filters] = useState([{label: 'Integration name', title: 'name'},{label: 'Configuration notes', title: 'configuration_notes'},
|
||||
|
||||
|
||||
|
||||
@ -94,19 +93,19 @@ const IntegrationsTablesPage = () => {
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/integrations/integrations-new'} color='info' label='New Item'/>}
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/integrations/integrations-new'} color='info' label='New integration'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getIntegrationsCSV} />
|
||||
<BaseButton className={'mr-3'} color='whiteDark' label='Export CSV' onClick={getIntegrationsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -116,9 +115,7 @@ const IntegrationsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/integrations/integrations-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton className={'ms-auto'} href={'/integrations/integrations-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
|
||||
|
||||
@ -1,273 +1,296 @@
|
||||
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
mdiArrowRight,
|
||||
mdiChartTimelineVariant,
|
||||
mdiClipboardCheckOutline,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiLockCheckOutline,
|
||||
mdiShieldCheckOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
|
||||
const demoAccounts = [
|
||||
{
|
||||
role: 'Admin workspace',
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '36b0782d',
|
||||
note: 'Full control over requests, approvals, tools, policies, and users.',
|
||||
},
|
||||
{
|
||||
role: 'Reviewer workspace',
|
||||
email: 'client@hello.com',
|
||||
password: '439992f6232c',
|
||||
note: 'Focused view for reviewing assigned legal AI workflows.',
|
||||
},
|
||||
];
|
||||
|
||||
const controlHighlights = [
|
||||
{
|
||||
title: 'AI intake',
|
||||
text: 'Matter type, data sensitivity, tool posture, owner, and expected ROI.',
|
||||
icon: mdiClipboardCheckOutline,
|
||||
},
|
||||
{
|
||||
title: 'Risk review',
|
||||
text: 'Partner, GC, security, and ethics approvals before production use.',
|
||||
icon: mdiShieldCheckOutline,
|
||||
},
|
||||
{
|
||||
title: 'Evidence trail',
|
||||
text: 'Human review, vendor posture, exceptions, and value captured in one place.',
|
||||
icon: mdiChartTimelineVariant,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState({
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '36b0782d',
|
||||
remember: true,
|
||||
});
|
||||
|
||||
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
password: '36b0782d',
|
||||
remember: true })
|
||||
|
||||
const title = 'Legal AI Governance Hub'
|
||||
const redirectTarget = useMemo(() => {
|
||||
const next = router.query.next;
|
||||
|
||||
if (typeof next === 'string' && next.startsWith('/') && !next.startsWith('//')) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return '/governance-workbench';
|
||||
}, [router.query.next]);
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage()
|
||||
const video = await getPexelsVideo()
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(findMe());
|
||||
}
|
||||
}, [token, dispatch]);
|
||||
// Redirect to dashboard if user is logged in
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.id) {
|
||||
router.push('/dashboard');
|
||||
router.push(redirectTarget);
|
||||
}
|
||||
}, [currentUser?.id, router]);
|
||||
// Show error message if there is one
|
||||
}, [currentUser?.id, redirectTarget, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage){
|
||||
notify('error', errorMessage)
|
||||
if (errorMessage) {
|
||||
toast(errorMessage, { type: 'error' });
|
||||
}
|
||||
}, [errorMessage]);
|
||||
|
||||
}, [errorMessage])
|
||||
// Show notification if there is one
|
||||
useEffect(() => {
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification)
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification])
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
if (notifyState?.showNotification) {
|
||||
toast(notifyState?.textNotification, { type: 'success' });
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [dispatch, notifyState?.showNotification, notifyState?.textNotification]);
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const {remember, ...rest} = value
|
||||
await dispatch(loginUser(rest));
|
||||
const { remember, ...credentials } = value;
|
||||
await dispatch(loginUser(credentials));
|
||||
};
|
||||
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
email : target.innerText.trim(),
|
||||
password: target.dataset.password ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
||||
style={{
|
||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}>
|
||||
<div className="flex justify-center w-full bg-blue-300/20">
|
||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
||||
by {image?.photographer} on Pexels</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video.user.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
const applyDemoAccount = (email: string, password: string) => {
|
||||
setInitialValues((previous) => ({
|
||||
...previous,
|
||||
email,
|
||||
password,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={contentPosition === 'background' ? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
} : {}}>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
<div className='min-h-screen bg-[#F6F3EC] text-[#0E1A2B]'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
|
||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||
|
||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||
|
||||
<div className='flex flex-row text-gray-500 justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="36b0782d"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>36b0782d</code>{' / '}
|
||||
to login as Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="439992f6232c"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>439992f6232c</code>{' / '}
|
||||
to login as User</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={mdiInformation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
<header className='border-b border-[#DDD5C7] bg-[#F6F3EC]/95'>
|
||||
<div className='mx-auto flex max-w-7xl items-center justify-between px-5 py-4 lg:px-8'>
|
||||
<Link href='/' className='flex items-center gap-3'>
|
||||
<span className='flex h-9 w-9 items-center justify-center rounded-md bg-[#0E1A2B] text-[#D8B75E]'>
|
||||
<BaseIcon path={mdiShieldCheckOutline} size={21} />
|
||||
</span>
|
||||
<span>
|
||||
<span className='block text-base font-semibold text-[#0E1A2B]'>Legal AI Governance Hub</span>
|
||||
<span className='block text-xs text-[#6F6657]'>Secure workspace access</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
||||
onClick={togglePasswordVisibility}
|
||||
>
|
||||
<BaseIcon
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
size={20}
|
||||
path={showPassword ? mdiEyeOff : mdiEye}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
<Link href='/' className='text-sm font-semibold text-[#6F6657] hover:text-[#0E1A2B]'>
|
||||
Back to overview
|
||||
</Link>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className='mx-auto grid min-h-[calc(100vh-74px)] max-w-7xl gap-8 px-5 py-8 lg:grid-cols-[0.95fr_1.05fr] lg:items-center lg:px-8'>
|
||||
<section className='overflow-hidden rounded-xl border border-[#C9BFAE] bg-[#07111F] text-white shadow-2xl shadow-[#83755E]/20'>
|
||||
<div className='border-b border-white/10 bg-white/[0.03] px-6 py-5'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-[#D8B75E]'>AI governance control plane</p>
|
||||
<h1 className='mt-2 text-3xl font-semibold tracking-tight text-white lg:text-4xl'>
|
||||
Sign in to the workspace that proves legal AI is controlled.
|
||||
</h1>
|
||||
</div>
|
||||
<span className='rounded-md border border-emerald-400/25 bg-emerald-400/10 px-3 py-1 text-xs font-semibold text-emerald-200'>
|
||||
Demo ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 p-6'>
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
{[
|
||||
{ value: '82', label: 'Governance score' },
|
||||
{ value: '12', label: 'Open reviews' },
|
||||
{ value: '4', label: 'Blocked actions' },
|
||||
].map((metric) => (
|
||||
<div key={metric.label} className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='text-3xl font-semibold text-white'>{metric.value}</div>
|
||||
<div className='mt-2 text-sm text-slate-400'>{metric.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3'>
|
||||
{controlHighlights.map((item) => (
|
||||
<div key={item.title} className='rounded-lg border border-white/10 bg-white/[0.04] p-4'>
|
||||
<div className='flex gap-3'>
|
||||
<div className='flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[#D8B75E]/15 text-[#D8B75E]'>
|
||||
<BaseIcon path={item.icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold text-white'>{item.title}</h2>
|
||||
<p className='mt-1 text-sm leading-6 text-slate-300'>{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-[#D8B75E]/25 bg-[#D8B75E]/10 p-4'>
|
||||
<p className='text-sm leading-6 text-[#F7E8B6]'>
|
||||
This is not an AI lawyer. It is the operating layer for use-case approval, vendor risk,
|
||||
human review, training, policy evidence, and ROI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='rounded-xl border border-[#DDD5C7] bg-white p-6 shadow-xl shadow-[#83755E]/10 lg:p-8'>
|
||||
<div className='mb-7'>
|
||||
<div className='mb-4 inline-flex items-center gap-2 rounded-md border border-[#D8B75E]/45 bg-[#FFF8E8] px-3 py-2 text-sm font-semibold text-[#7A5B13]'>
|
||||
<BaseIcon path={mdiLockCheckOutline} size={18} />
|
||||
Protected legal AI workspace
|
||||
</div>
|
||||
<h2 className='text-3xl font-semibold text-[#0E1A2B]'>Enter the governance hub</h2>
|
||||
<p className='mt-3 text-base leading-7 text-[#5B6472]'>
|
||||
Use the demo credentials below to open the workspace and continue the conference-ready product story.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 grid gap-3 md:grid-cols-2'>
|
||||
{demoAccounts.map((account) => (
|
||||
<button
|
||||
key={account.email}
|
||||
type='button'
|
||||
onClick={() => applyDemoAccount(account.email, account.password)}
|
||||
className='rounded-lg border border-[#DDD5C7] bg-[#FBF8F1] p-4 text-left transition hover:border-[#D8B75E] hover:bg-[#FFF8E8]'
|
||||
>
|
||||
<span className='text-sm font-semibold text-[#0E1A2B]'>{account.role}</span>
|
||||
<span className='mt-2 block font-mono text-sm text-[#7A5B13]'>{account.email}</span>
|
||||
<span className='mt-1 block font-mono text-xs text-[#6F6657]'>{account.password}</span>
|
||||
<span className='mt-3 block text-xs leading-5 text-[#6B7280]'>{account.note}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
|
||||
<Form className='space-y-5'>
|
||||
<label className='block'>
|
||||
<span className='text-sm font-semibold text-[#374151]'>Email</span>
|
||||
<Field
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
className='mt-2 h-12 w-full rounded-lg border border-[#C9BFAE] bg-white px-4 text-[#111827] outline-none transition focus:border-[#D8B75E] focus:ring-2 focus:ring-[#D8B75E]/30'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='block'>
|
||||
<span className='text-sm font-semibold text-[#374151]'>Password</span>
|
||||
<div className='relative mt-2'>
|
||||
<Field
|
||||
name='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete='current-password'
|
||||
className='h-12 w-full rounded-lg border border-[#C9BFAE] bg-white px-4 pr-12 text-[#111827] outline-none transition focus:border-[#D8B75E] focus:ring-2 focus:ring-[#D8B75E]/30'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
className='absolute inset-y-0 right-0 flex w-12 items-center justify-center text-[#6B7280] hover:text-[#0E1A2B]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
<BaseIcon path={showPassword ? mdiEyeOff : mdiEye} size={21} />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-3 text-sm'>
|
||||
<label className='inline-flex items-center gap-2 text-[#5B6472]'>
|
||||
<Field
|
||||
type='checkbox'
|
||||
name='remember'
|
||||
className='h-4 w-4 rounded border-[#C9BFAE] text-[#0E1A2B] focus:ring-[#D8B75E]'
|
||||
/>
|
||||
Remember this device
|
||||
</label>
|
||||
|
||||
<Link className='font-semibold text-[#7A5B13] hover:text-[#0E1A2B]' href='/forgot'>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isFetching}
|
||||
className='inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg border border-[#0E1A2B] bg-[#0E1A2B] px-5 text-sm font-semibold text-white transition hover:bg-[#162742] disabled:cursor-not-allowed disabled:opacity-70'
|
||||
>
|
||||
{isFetching ? 'Checking access...' : 'Enter governance workspace'}
|
||||
<BaseIcon path={mdiArrowRight} size={18} />
|
||||
</button>
|
||||
|
||||
<p className='text-center text-sm text-[#6B7280]'>
|
||||
Need a new account?{' '}
|
||||
<Link className='font-semibold text-[#7A5B13] hover:text-[#0E1A2B]' href='/register'>
|
||||
Create access
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TablePractice_groups from '../../components/Practice_groups/TablePractice_groups'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,13 +34,13 @@ const Practice_groupsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'PracticeGroupName', title: 'name'},{label: 'Description', title: 'description'},
|
||||
const [filters] = useState([{label: 'Practice group name', title: 'name'},{label: 'Description', title: 'description'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'PracticeGroupLead', title: 'lead_user'},
|
||||
{label: 'Practice group lead', title: 'lead_user'},
|
||||
|
||||
|
||||
|
||||
@ -90,27 +90,33 @@ const Practice_groupsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Practice_groups')}</title>
|
||||
<title>{getPageTitle('Practice groups')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Practice_groups" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Practice groups" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/practice_groups/practice_groups-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Legal ownership map'
|
||||
title='Route AI governance work to the right legal practice teams'
|
||||
description='Maintain practice groups, group leads, active status, and descriptions so AI use cases, reviews, and policies can be assigned to accountable legal owners.'
|
||||
metrics={[
|
||||
{ label: 'Ownership', value: 'Practice lead', helper: 'Each group can anchor review responsibility and escalation.' },
|
||||
{ label: 'Routing', value: 'Use-case context', helper: 'AI requests inherit the right legal domain and reviewer context.' },
|
||||
{ label: 'Governance', value: 'Active groups', helper: 'Inactive groups stay out of day-to-day routing decisions.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/practice_groups/practice_groups-new'} color='info' label='New practice group'/>}
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPractice_groupsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getPractice_groupsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -120,11 +126,9 @@ const Practice_groupsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/practice_groups/practice_groups-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton href={'/practice_groups/practice_groups-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TablePractice_groups
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TablePractice_groups from '../../components/Practice_groups/TablePractice_groups'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,13 +34,13 @@ const Practice_groupsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'PracticeGroupName', title: 'name'},{label: 'Description', title: 'description'},
|
||||
const [filters] = useState([{label: 'Practice group name', title: 'name'},{label: 'Description', title: 'description'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'PracticeGroupLead', title: 'lead_user'},
|
||||
{label: 'Practice group lead', title: 'lead_user'},
|
||||
|
||||
|
||||
|
||||
@ -90,27 +90,33 @@ const Practice_groupsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Practice_groups')}</title>
|
||||
<title>{getPageTitle('Practice groups table')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Practice_groups" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Practice groups table" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/practice_groups/practice_groups-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Legal ownership map'
|
||||
title='Practice groups as an editable governance table'
|
||||
description='Use table view to scan group names, descriptions, active flags, and lead ownership across the legal organization.'
|
||||
metrics={[
|
||||
{ label: 'View mode', value: 'Table', helper: 'Best for quickly comparing leads and status across groups.' },
|
||||
{ label: 'Routing', value: 'Practice context', helper: 'AI requests can be aligned to the right legal domain.' },
|
||||
{ label: 'Ownership', value: 'Lead user', helper: 'Each group can own review paths and escalation.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/practice_groups/practice_groups-new'} color='info' label='New practice group'/>}
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPractice_groupsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getPractice_groupsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -118,13 +124,9 @@ const Practice_groupsTablesPage = () => {
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
|
||||
<Link href={'/practice_groups/practice_groups-list'}>
|
||||
Back to <span className='capitalize'>card</span>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</CardBox>
|
||||
<BaseButton href={'/practice_groups/practice_groups-list'} color='whiteDark' label='Card view'/>
|
||||
</LegalOpsPageIntro>
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TablePractice_groups
|
||||
filterItems={filterItems}
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableTraining_courses from '../../components/Training_courses/TableTraining_courses'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,13 +34,13 @@ const Training_coursesTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'CourseName', title: 'name'},{label: 'Description', title: 'description'},{label: 'Link', title: 'link'},
|
||||
{label: 'DurationMinutes', title: 'duration_minutes', number: 'true'},{label: 'ValidityDays', title: 'validity_days', number: 'true'},
|
||||
const [filters] = useState([{label: 'Course name', title: 'name'},{label: 'Description', title: 'description'},{label: 'Link', title: 'link'},
|
||||
{label: 'Duration minutes', title: 'duration_minutes', number: 'true'},{label: 'Validity days', title: 'validity_days', number: 'true'},
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'DeliveryType', title: 'delivery_type', type: 'enum', options: ['video','live','document','lms_link']},{label: 'Status', title: 'status', type: 'enum', options: ['draft','active','archived']},
|
||||
{label: 'Delivery type', title: 'delivery_type', type: 'enum', options: ['video','live','document','lms_link']},{label: 'Status', title: 'status', type: 'enum', options: ['draft','active','archived']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_TRAINING_COURSES');
|
||||
@ -86,27 +86,33 @@ const Training_coursesTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Training_courses')}</title>
|
||||
<title>{getPageTitle('Training courses')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Training_courses" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Training courses" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/training_courses/training_courses-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='AI adoption training'
|
||||
title='Track the courses that authorize people to use approved AI workflows'
|
||||
description='Maintain training content, delivery mode, validity windows, attachments, and status so legal teams can prove users were trained before using sensitive AI tools.'
|
||||
metrics={[
|
||||
{ label: 'Training scope', value: 'Role readiness', helper: 'Courses connect AI access with documented user preparation.' },
|
||||
{ label: 'Governance signal', value: 'Validity window', helper: 'Expiration periods keep training evidence fresh.' },
|
||||
{ label: 'Delivery', value: 'Live / LMS / docs', helper: 'Support lightweight internal training and formal LMS links.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/training_courses/training_courses-new'} color='info' label='New course'/>}
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getTraining_coursesCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getTraining_coursesCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -116,11 +122,9 @@ const Training_coursesTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/training_courses/training_courses-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton href={'/training_courses/training_courses-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableTraining_courses
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableTraining_courses from '../../components/Training_courses/TableTraining_courses'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,13 +34,13 @@ const Training_coursesTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'CourseName', title: 'name'},{label: 'Description', title: 'description'},{label: 'Link', title: 'link'},
|
||||
{label: 'DurationMinutes', title: 'duration_minutes', number: 'true'},{label: 'ValidityDays', title: 'validity_days', number: 'true'},
|
||||
const [filters] = useState([{label: 'Course name', title: 'name'},{label: 'Description', title: 'description'},{label: 'Link', title: 'link'},
|
||||
{label: 'Duration minutes', title: 'duration_minutes', number: 'true'},{label: 'Validity days', title: 'validity_days', number: 'true'},
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'DeliveryType', title: 'delivery_type', type: 'enum', options: ['video','live','document','lms_link']},{label: 'Status', title: 'status', type: 'enum', options: ['draft','active','archived']},
|
||||
{label: 'Delivery type', title: 'delivery_type', type: 'enum', options: ['video','live','document','lms_link']},{label: 'Status', title: 'status', type: 'enum', options: ['draft','active','archived']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_TRAINING_COURSES');
|
||||
@ -86,27 +86,33 @@ const Training_coursesTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Training_courses')}</title>
|
||||
<title>{getPageTitle('Training courses table')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Training_courses" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Training courses table" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/training_courses/training_courses-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='AI adoption training'
|
||||
title='Training courses as an editable governance table'
|
||||
description='Use table view to scan delivery type, validity, status, links, and attachments across the training catalog.'
|
||||
metrics={[
|
||||
{ label: 'View mode', value: 'Table', helper: 'Best for comparing course duration, status, and validity quickly.' },
|
||||
{ label: 'Access control', value: 'Training evidence', helper: 'Courses support user readiness for approved AI tools.' },
|
||||
{ label: 'Lifecycle', value: 'Draft / active', helper: 'Keep archived courses separate from active governance content.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/training_courses/training_courses-new'} color='info' label='New course'/>}
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getTraining_coursesCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getTraining_coursesCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -114,13 +120,9 @@ const Training_coursesTablesPage = () => {
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
|
||||
<Link href={'/training_courses/training_courses-list'}>
|
||||
Back to <span className='capitalize'>card</span>
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</CardBox>
|
||||
<BaseButton href={'/training_courses/training_courses-list'} color='whiteDark' label='Card view'/>
|
||||
</LegalOpsPageIntro>
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableTraining_courses
|
||||
filterItems={filterItems}
|
||||
|
||||
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableVendor_risk_assessments from '../../components/Vendor_risk_assessments/TableVendor_risk_assessments'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -35,24 +35,24 @@ const Vendor_risk_assessmentsTablesPage = () => {
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Findings', title: 'findings'},{label: 'Mitigations', title: 'mitigations'},
|
||||
{label: 'ConfidentialityScore', title: 'confidentiality_score', number: 'true'},{label: 'SecurityScore', title: 'security_score', number: 'true'},{label: 'IntegrationReadinessScore', title: 'integration_readiness_score', number: 'true'},{label: 'LegalSpecificRiskScore', title: 'legal_specific_risk_score', number: 'true'},{label: 'PricingScore', title: 'pricing_score', number: 'true'},{label: 'SupportScore', title: 'support_score', number: 'true'},{label: 'ComplianceScore', title: 'compliance_score', number: 'true'},{label: 'OverallScore', title: 'overall_score', number: 'true'},
|
||||
{label: 'Confidentiality score', title: 'confidentiality_score', number: 'true'},{label: 'Security score', title: 'security_score', number: 'true'},{label: 'Integration readiness', title: 'integration_readiness_score', number: 'true'},{label: 'Legal-specific risk', title: 'legal_specific_risk_score', number: 'true'},{label: 'Pricing score', title: 'pricing_score', number: 'true'},{label: 'Support score', title: 'support_score', number: 'true'},{label: 'Compliance score', title: 'compliance_score', number: 'true'},{label: 'Overall score', title: 'overall_score', number: 'true'},
|
||||
|
||||
{label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'CompletedAt', title: 'completed_at', date: 'true'},
|
||||
{label: 'Started at', title: 'started_at', date: 'true'},{label: 'Completed at', title: 'completed_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Vendor', title: 'vendor'},
|
||||
|
||||
|
||||
|
||||
{label: 'AITool', title: 'tool'},
|
||||
{label: 'AI tool', title: 'tool'},
|
||||
|
||||
|
||||
|
||||
{label: 'AssessmentOwner', title: 'owner'},
|
||||
{label: 'Assessment owner', title: 'owner'},
|
||||
|
||||
|
||||
|
||||
{label: 'AssessmentStatus', title: 'assessment_status', type: 'enum', options: ['draft','in_review','approved','rejected','needs_changes']},
|
||||
{label: 'Assessment status', title: 'assessment_status', type: 'enum', options: ['draft','in_review','approved','rejected','needs_changes']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_VENDOR_RISK_ASSESSMENTS');
|
||||
@ -98,27 +98,34 @@ const Vendor_risk_assessmentsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Vendor_risk_assessments')}</title>
|
||||
<title>{getPageTitle('Vendor risk assessments')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Vendor_risk_assessments" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Vendor risk assessments" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/vendor_risk_assessments/vendor_risk_assessments-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Vendor diligence'
|
||||
title='Score AI vendors before they become client-data dependencies'
|
||||
description='Compare confidentiality, security, legal-specific risk, integration readiness, pricing, support, and compliance in a single defensible assessment record.'
|
||||
metrics={[
|
||||
{ label: 'Risk lens', value: 'Legal specific', helper: 'Confidentiality and privilege concerns are scored directly.' },
|
||||
{ label: 'Readiness', value: 'Integration fit', helper: 'Security, support, and implementation signals are visible together.' },
|
||||
{ label: 'Outcome', value: 'Approved path', helper: 'Findings and mitigations stay attached to the decision.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/vendor_risk_assessments/vendor_risk_assessments-new'} color='info' label='New assessment'/>}
|
||||
<BaseButton href={'/governance-workbench'} color='whiteDark' label='Governance workbench'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getVendor_risk_assessmentsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getVendor_risk_assessmentsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -128,7 +135,7 @@ const Vendor_risk_assessmentsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableVendor_risk_assessments
|
||||
|
||||
39
frontend/src/pages/vendors/vendors-list.tsx
vendored
39
frontend/src/pages/vendors/vendors-list.tsx
vendored
@ -6,11 +6,11 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import LegalOpsPageIntro from '../../components/LegalOpsPageIntro'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableVendors from '../../components/Vendors/TableVendors'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
@ -34,13 +34,13 @@ const VendorsTablesPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'VendorName', title: 'name'},{label: 'Website', title: 'website'},{label: 'PrimaryContactName', title: 'primary_contact_name'},{label: 'PrimaryContactEmail', title: 'primary_contact_email'},{label: 'Notes', title: 'notes'},
|
||||
const [filters] = useState([{label: 'Vendor name', title: 'name'},{label: 'Website', title: 'website'},{label: 'Primary contact', title: 'primary_contact_name'},{label: 'Contact email', title: 'primary_contact_email'},{label: 'Notes', title: 'notes'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'VendorStatus', title: 'vendor_status', type: 'enum', options: ['active','in_review','suspended','retired']},
|
||||
{label: 'Vendor status', title: 'vendor_status', type: 'enum', options: ['active','in_review','suspended','retired']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_VENDORS');
|
||||
@ -86,27 +86,34 @@ const VendorsTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Vendors')}</title>
|
||||
<title>{getPageTitle('Vendor registry')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Vendors" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Vendor registry" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/vendors/vendors-new'} color='info' label='New Item'/>}
|
||||
|
||||
<LegalOpsPageIntro
|
||||
eyebrow='Third-party control'
|
||||
title='Keep every AI and legal technology vendor in one reviewed registry'
|
||||
description='Manage vendor ownership, contact context, current status, notes, and downstream risk assessments before teams connect tools to client or matter data.'
|
||||
metrics={[
|
||||
{ label: 'Vendor state', value: 'Active / review', helper: 'Separate approved providers from tools still under diligence.' },
|
||||
{ label: 'Accountability', value: 'Named owners', helper: 'Contacts and notes stay tied to the vendor record.' },
|
||||
{ label: 'Governance link', value: 'Risk ready', helper: 'Each vendor can feed tool approvals and assessment workflows.' },
|
||||
]}
|
||||
>
|
||||
{hasCreatePermission && <BaseButton href={'/vendors/vendors-new'} color='info' label='New vendor'/>}
|
||||
<BaseButton href={'/governance-workbench'} color='whiteDark' label='Governance workbench'/>
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
label='Add filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getVendorsCSV} />
|
||||
<BaseButton color='whiteDark' label='Export CSV' onClick={getVendorsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
color='whiteDark'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
@ -116,11 +123,9 @@ const VendorsTablesPage = () => {
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/vendors/vendors-table'}>Switch to Table</Link>
|
||||
</div>
|
||||
<BaseButton href={'/vendors/vendors-table'} color='whiteDark' label='Table view'/>
|
||||
|
||||
</CardBox>
|
||||
</LegalOpsPageIntro>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableVendors
|
||||
|
||||
@ -25,29 +25,29 @@ interface StyleObject {
|
||||
}
|
||||
|
||||
export const white: StyleObject = {
|
||||
aside: 'bg-white dark:text-white',
|
||||
aside: 'bg-[#FBF8F1] text-[#374151] dark:text-white',
|
||||
asideScrollbars: 'aside-scrollbars-light',
|
||||
asideBrand: '',
|
||||
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
|
||||
asideMenuItemActive: 'font-bold text-black dark:text-white',
|
||||
asideMenuDropdown: 'bg-gray-100/75',
|
||||
navBarItemLabel: 'text-blue-600',
|
||||
navBarItemLabelHover: 'hover:text-black',
|
||||
navBarItemLabelActiveColor: 'text-black',
|
||||
overlay: 'from-white via-gray-100 to-white',
|
||||
activeLinkColor: 'bg-gray-100/70',
|
||||
bgLayoutColor: 'bg-gray-50',
|
||||
iconsColor: 'text-blue-500',
|
||||
asideMenuItem: 'text-[#374151] hover:bg-[#F1E7C7]/70 hover:text-[#0E1A2B] dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
|
||||
asideMenuItemActive: 'font-semibold text-[#0E1A2B] dark:text-white',
|
||||
asideMenuDropdown: 'bg-transparent',
|
||||
navBarItemLabel: 'text-[#4B5563]',
|
||||
navBarItemLabelHover: 'hover:text-[#0E1A2B]',
|
||||
navBarItemLabelActiveColor: 'text-[#0E1A2B]',
|
||||
overlay: 'from-[#F6F3EC] via-[#E9E1D2] to-[#F6F3EC]',
|
||||
activeLinkColor: 'bg-[#F1E7C7]',
|
||||
bgLayoutColor: 'bg-[#F6F3EC]',
|
||||
iconsColor: 'text-[#7A5B13]',
|
||||
cardsColor: 'bg-white',
|
||||
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
|
||||
corners: 'rounded',
|
||||
cardsStyle: 'bg-white border border-pavitra-400',
|
||||
linkColor: 'text-blue-600',
|
||||
websiteHeder: 'border-b border-gray-200',
|
||||
borders: 'border-gray-200',
|
||||
shadow: '',
|
||||
focusRingColor: 'focus:ring focus:ring-[#D8B75E]/35 focus:border-[#D8B75E] focus:outline-none border-[#C9BFAE] dark:focus:ring-[#D8B75E] dark:focus:border-[#D8B75E]',
|
||||
corners: 'rounded-lg',
|
||||
cardsStyle: 'bg-white border border-[#DDD5C7] shadow-sm shadow-[#83755E]/10',
|
||||
linkColor: 'text-[#7A5B13]',
|
||||
websiteHeder: 'border-b border-[#DDD5C7]',
|
||||
borders: 'border-[#DDD5C7]',
|
||||
shadow: 'shadow-sm shadow-[#83755E]/10',
|
||||
websiteSectionStyle: '',
|
||||
textSecondary: 'text-gray-500',
|
||||
textSecondary: 'text-[#6B7280]',
|
||||
}
|
||||
|
||||
|
||||
@ -57,10 +57,13 @@ export const white: StyleObject = {
|
||||
export const dataGridStyles = {
|
||||
'& .MuiDataGrid-cell': {
|
||||
paddingX: 3,
|
||||
border: 'none',
|
||||
borderColor: '#E5E0D6',
|
||||
color: '#374151',
|
||||
},
|
||||
'& .MuiDataGrid-columnHeader': {
|
||||
paddingX: 3,
|
||||
color: '#0E1A2B',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .MuiDataGrid-columnHeaderCheckbox': {
|
||||
paddingX: 0,
|
||||
@ -69,14 +72,24 @@ export const dataGridStyles = {
|
||||
paddingY: 4,
|
||||
borderStartStartRadius: 7,
|
||||
borderStartEndRadius: 7,
|
||||
backgroundColor: '#FBF8F1',
|
||||
borderColor: '#DDD5C7',
|
||||
},
|
||||
'& .MuiDataGrid-footerContainer': {
|
||||
paddingY: 0.5,
|
||||
borderEndStartRadius: 7,
|
||||
borderEndEndRadius: 7,
|
||||
borderColor: '#DDD5C7',
|
||||
backgroundColor: '#FBF8F1',
|
||||
},
|
||||
'& .MuiDataGrid-root': {
|
||||
border: 'none',
|
||||
border: '1px solid #DDD5C7',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
'& .MuiDataGrid-row:hover': {
|
||||
backgroundColor: '#FFF8E8',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user