This commit is contained in:
Flatlogic Bot 2026-05-11 12:32:55 +00:00
parent 1ef812ac7e
commit 97439eda85
59 changed files with 4153 additions and 3661 deletions

View File

@ -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}:

View File

@ -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}:

View File

@ -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;
}
}
};

View File

@ -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;
}
}
};

View File

@ -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",
},
}

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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
/>

View File

@ -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"

View File

@ -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;

View File

@ -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) {

View File

@ -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

View File

@ -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>

View File

@ -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 modepages 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;

View File

@ -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>
&copy;{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]'>&copy; {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>
)

View File

@ -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}

View File

@ -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}>

View File

@ -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}`}

View File

@ -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>

View 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>
)
}

View File

@ -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>

View File

@ -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} />

View File

@ -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'
}`

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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!'

View 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);
}

View File

@ -11,6 +11,7 @@ export type MenuAsideItem = {
target?: string
color?: ColorButtonKey
isLogout?: boolean
isOpenByDefault?: boolean
withDevider?: boolean;
menu?: MenuAsideItem[]
permissions?: string | string[]

View File

@ -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>
)

View File

@ -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',
},
],
},
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

View 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;

View File

@ -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>;
};

View File

@ -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>

View File

@ -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'}>
Dont 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>
);
}

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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',
},
};