482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
import {
|
|
mdiAlertCircleOutline,
|
|
mdiArrowLeft,
|
|
mdiCheckCircleOutline,
|
|
mdiOpenInNew,
|
|
mdiPencilOutline,
|
|
mdiSendCheckOutline,
|
|
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 CardBox from '../../components/CardBox';
|
|
import NotificationBar from '../../components/NotificationBar';
|
|
import SectionMain from '../../components/SectionMain';
|
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../../config';
|
|
import {
|
|
approvalStepMeta,
|
|
getDecisionBadge,
|
|
getRiskBadge,
|
|
getStatusBadge,
|
|
} from '../../helpers/legalAiFormatting';
|
|
import { hasPermission } from '../../helpers/userPermissions';
|
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
|
import { fetch } from '../../stores/ai_use_cases/ai_use_casesSlice';
|
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
|
import { useRouter } from 'next/router';
|
|
|
|
type ApprovalStepRecord = {
|
|
id: string;
|
|
step_type?: string;
|
|
step_order?: number;
|
|
assigned_at?: string;
|
|
decided_at?: string;
|
|
decision?: string;
|
|
comments?: string;
|
|
assigned_reviewer?: { firstName?: string; lastName?: string };
|
|
};
|
|
|
|
const ACTIVE_REVIEW_STATUSES = new Set([
|
|
'submitted',
|
|
'risk_review',
|
|
'security_review',
|
|
'ethics_review',
|
|
]);
|
|
|
|
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 this AI use case.';
|
|
}
|
|
|
|
function formatDate(value?: string | null) {
|
|
if (!value) {
|
|
return 'Not yet';
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: '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 DetailItem = ({ label, value }: { label: string; value?: string | number | null }) => (
|
|
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4'>
|
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-500'>{label}</p>
|
|
<p className='mt-2 text-sm leading-6 text-slate-900'>{value !== undefined && value !== null && value !== '' ? value : 'Not set'}</p>
|
|
</div>
|
|
);
|
|
|
|
const AiUseCasesView = () => {
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const { ai_use_cases, loading } = useAppSelector((state) => state.ai_use_cases);
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const [approvalSteps, setApprovalSteps] = useState<ApprovalStepRecord[]>([]);
|
|
const [pageError, setPageError] = useState('');
|
|
const [actionMessage, setActionMessage] = useState('');
|
|
const [decisionComment, setDecisionComment] = useState('');
|
|
const [isWorking, setIsWorking] = useState(false);
|
|
const [timelineLoading, setTimelineLoading] = useState(false);
|
|
|
|
const id = typeof router.query.id === 'string' ? router.query.id : '';
|
|
const created = router.query.created === '1';
|
|
const submitted = router.query.submitted === '1';
|
|
|
|
const useCase = useMemo(() => {
|
|
if (!ai_use_cases || Array.isArray(ai_use_cases)) {
|
|
return null;
|
|
}
|
|
|
|
return ai_use_cases;
|
|
}, [ai_use_cases]);
|
|
|
|
const loadUseCase = async () => {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setPageError('');
|
|
await dispatch(fetch({ id })).unwrap();
|
|
} catch (error) {
|
|
console.error('Failed to load AI use case:', error);
|
|
setPageError(extractErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const loadApprovalSteps = async () => {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
setTimelineLoading(true);
|
|
|
|
try {
|
|
const response = await axios.get(`/approval_steps?limit=50&page=0&use_case=${id}`);
|
|
const records = response.data?.rows || [];
|
|
records.sort((first: ApprovalStepRecord, second: ApprovalStepRecord) => (first.step_order || 0) - (second.step_order || 0));
|
|
setApprovalSteps(records);
|
|
} catch (error) {
|
|
console.error('Failed to load approval steps:', error);
|
|
setPageError(extractErrorMessage(error));
|
|
} finally {
|
|
setTimelineLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || !id) {
|
|
return;
|
|
}
|
|
|
|
loadUseCase();
|
|
loadApprovalSteps();
|
|
}, [dispatch, id, router.isReady]);
|
|
|
|
useEffect(() => {
|
|
if (created) {
|
|
setActionMessage('Draft saved. Review the details below, then submit it when you are ready to start governance review.');
|
|
}
|
|
|
|
if (submitted) {
|
|
setActionMessage('The AI use case has been submitted and the approval workflow has been created.');
|
|
}
|
|
}, [created, submitted]);
|
|
|
|
const activeStep = useMemo(() => {
|
|
if (!ACTIVE_REVIEW_STATUSES.has(useCase?.status || '')) {
|
|
return undefined;
|
|
}
|
|
|
|
return [...approvalSteps]
|
|
.filter((step) => step.decision === 'pending')
|
|
.sort((first, second) => (first.step_order || 0) - (second.step_order || 0))[0];
|
|
}, [approvalSteps, useCase?.status]);
|
|
|
|
const canSubmitForReview = Boolean(
|
|
useCase &&
|
|
hasPermission(currentUser, 'UPDATE_AI_USE_CASES') &&
|
|
(useCase.status === 'draft' || !useCase.status) &&
|
|
approvalSteps.length === 0,
|
|
);
|
|
|
|
const canReviewStep = Boolean(
|
|
activeStep && hasPermission(currentUser, 'UPDATE_APPROVAL_STEPS') && ACTIVE_REVIEW_STATUSES.has(useCase?.status || ''),
|
|
);
|
|
|
|
const statusBadge = getStatusBadge(useCase?.status);
|
|
const riskBadge = getRiskBadge(useCase?.risk_level);
|
|
|
|
const handleRefresh = async () => {
|
|
await loadUseCase();
|
|
await loadApprovalSteps();
|
|
};
|
|
|
|
const handleSubmitForReview = async () => {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
setIsWorking(true);
|
|
setPageError('');
|
|
|
|
try {
|
|
await axios.put(`/ai_use_cases/${id}/submit`);
|
|
setActionMessage('Submitted for partner, GC, security, and ethics review.');
|
|
await handleRefresh();
|
|
} catch (error) {
|
|
console.error('Failed to submit AI use case for review:', error);
|
|
setPageError(extractErrorMessage(error));
|
|
} finally {
|
|
setIsWorking(false);
|
|
}
|
|
};
|
|
|
|
const handleDecision = async (decision: 'approved' | 'rejected' | 'needs_changes') => {
|
|
if (!activeStep) {
|
|
return;
|
|
}
|
|
|
|
setIsWorking(true);
|
|
setPageError('');
|
|
|
|
try {
|
|
await axios.put(`/approval_steps/${activeStep.id}/decision`, {
|
|
decision,
|
|
comments: decisionComment,
|
|
});
|
|
|
|
setDecisionComment('');
|
|
setActionMessage(
|
|
decision === 'approved'
|
|
? 'Approval recorded and the workflow advanced to the next reviewer.'
|
|
: decision === 'needs_changes'
|
|
? 'Reviewer feedback was captured and the request is now marked as needing changes.'
|
|
: 'The use case has been rejected and the decision is now part of the record.',
|
|
);
|
|
|
|
await handleRefresh();
|
|
} catch (error) {
|
|
console.error('Failed to record approval decision:', error);
|
|
setPageError(extractErrorMessage(error));
|
|
} finally {
|
|
setIsWorking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle(useCase?.title || 'AI Use Case')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={mdiShieldCheckOutline} title='AI use case detail' main>
|
|
<div className='flex flex-wrap gap-3'>
|
|
<BaseButton color='whiteDark' label='Back to register' icon={mdiArrowLeft} href='/ai_use_cases/ai_use_cases-list' />
|
|
<BaseButton color='whiteDark' label='Workbench' icon={mdiOpenInNew} href='/governance-workbench' />
|
|
</div>
|
|
</SectionTitleLineWithButton>
|
|
|
|
{actionMessage && (
|
|
<NotificationBar color='success' icon={mdiCheckCircleOutline}>
|
|
{actionMessage}
|
|
</NotificationBar>
|
|
)}
|
|
|
|
{pageError && (
|
|
<NotificationBar color='danger' icon={mdiAlertCircleOutline}>
|
|
{pageError}
|
|
</NotificationBar>
|
|
)}
|
|
|
|
<CardBox className='mb-6 border border-[#D6E0F5] bg-white'>
|
|
<div className='grid gap-6 xl:grid-cols-[1.45fr_0.9fr]'>
|
|
<div>
|
|
<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>
|
|
<h2 className='mt-4 text-3xl font-semibold text-slate-950'>
|
|
{useCase?.title || (loading ? 'Loading AI use case…' : 'Untitled AI use case')}
|
|
</h2>
|
|
<p className='mt-4 max-w-3xl text-base leading-8 text-slate-500'>
|
|
{useCase?.description || 'No workflow summary has been added yet.'}
|
|
</p>
|
|
<div className='mt-6 flex flex-wrap gap-3'>
|
|
<BaseButton color='whiteDark' label='Edit draft' icon={mdiPencilOutline} href={`/ai_use_cases/ai_use_cases-edit/?id=${id}`} />
|
|
{canSubmitForReview && (
|
|
<BaseButton
|
|
color='info'
|
|
label={isWorking ? 'Submitting…' : 'Submit for review'}
|
|
icon={mdiSendCheckOutline}
|
|
onClick={handleSubmitForReview}
|
|
disabled={isWorking}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='rounded-3xl border border-slate-200 bg-[#F8FBFF] p-6'>
|
|
<p className='text-sm font-semibold uppercase tracking-[0.16em] text-[#1D4ED8]'>Workflow snapshot</p>
|
|
<div className='mt-4 space-y-4'>
|
|
<DetailItem label='Owner' value={formatPerson(useCase?.owner)} />
|
|
<DetailItem label='Practice group' value={useCase?.practice_group?.name} />
|
|
<DetailItem label='Matter type' value={useCase?.matter_type?.name} />
|
|
<DetailItem label='Intended tool' value={useCase?.intended_tool?.name} />
|
|
<DetailItem label='Expected hours saved' value={useCase?.expected_hours_saved ? `${useCase.expected_hours_saved} hours` : 'Not estimated'} />
|
|
<DetailItem label='Submitted at' value={formatDate(useCase?.submitted_at)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<div className='mb-6 grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
|
|
<CardBox className='border border-[#D6E0F5] bg-white'>
|
|
<div>
|
|
<h3 className='text-xl font-semibold text-slate-950'>Business goal</h3>
|
|
<p className='mt-3 text-base leading-8 text-slate-600'>
|
|
{useCase?.business_goal || 'No business goal has been provided yet.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className='mt-8'>
|
|
<h3 className='text-xl font-semibold text-slate-950'>Reviewer notes & known guardrails</h3>
|
|
<p className='mt-3 whitespace-pre-wrap text-base leading-8 text-slate-600'>
|
|
{useCase?.review_notes || 'No guardrails or reviewer notes have been captured yet.'}
|
|
</p>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className='border border-[#D6E0F5] bg-[#0F172A] text-white'>
|
|
<div className='space-y-5'>
|
|
<div>
|
|
<p className='text-sm font-semibold uppercase tracking-[0.16em] text-slate-300'>Current stage</p>
|
|
<h3 className='mt-3 text-2xl font-semibold'>
|
|
{activeStep
|
|
? approvalStepMeta[activeStep.step_type as keyof typeof approvalStepMeta]?.label || 'Review in progress'
|
|
: useCase?.status === 'approved'
|
|
? 'Approved for use'
|
|
: useCase?.status === 'rejected'
|
|
? 'Rejected'
|
|
: useCase?.status === 'needs_changes'
|
|
? 'Needs changes'
|
|
: 'Draft'}
|
|
</h3>
|
|
</div>
|
|
<div className='rounded-2xl border border-white/10 bg-white/5 p-4 text-sm leading-7 text-slate-300'>
|
|
{activeStep
|
|
? approvalStepMeta[activeStep.step_type as keyof typeof approvalStepMeta]?.description || 'The request is waiting on the next reviewer.'
|
|
: 'No active approval step yet. Submit the draft to begin governance review.'}
|
|
</div>
|
|
<div className='grid gap-3'>
|
|
{[
|
|
{ label: 'Created', value: formatDate(useCase?.createdAt) },
|
|
{ label: 'Approved', value: formatDate(useCase?.approved_at) },
|
|
{ label: 'Active reviewer', value: activeStep ? formatPerson(activeStep.assigned_reviewer) : 'Not assigned' },
|
|
].map((item) => (
|
|
<div key={item.label} className='rounded-2xl border border-white/10 bg-white/5 px-4 py-3'>
|
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>{item.label}</p>
|
|
<p className='mt-2 text-sm text-white'>{item.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<CardBox className='border border-[#D6E0F5] bg-white'>
|
|
<div className='mb-6 flex items-start justify-between gap-4'>
|
|
<div>
|
|
<h3 className='text-xl font-semibold text-slate-950'>Approval trail</h3>
|
|
<p className='mt-2 text-sm leading-7 text-slate-500'>Comments, reviewer assignments, and decision history for this AI workflow request.</p>
|
|
</div>
|
|
<BaseButton color='whiteDark' small label='All approval steps' href='/approval_steps/approval_steps-list' />
|
|
</div>
|
|
|
|
<div className='space-y-4'>
|
|
{approvalSteps.length ? (
|
|
approvalSteps.map((step) => {
|
|
const stepMeta = approvalStepMeta[step.step_type as keyof typeof approvalStepMeta];
|
|
const decisionBadge = getDecisionBadge(step.decision);
|
|
const isActiveStep = activeStep?.id === step.id;
|
|
|
|
return (
|
|
<div key={step.id} className='rounded-3xl border border-slate-200 bg-slate-50 p-5'>
|
|
<div className='flex flex-wrap items-start justify-between gap-4'>
|
|
<div>
|
|
<div className='flex items-center gap-3'>
|
|
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-white text-sm font-semibold text-slate-700'>
|
|
{step.step_order || '—'}
|
|
</div>
|
|
<div>
|
|
<p className='text-sm font-semibold uppercase tracking-[0.16em] text-slate-500'>
|
|
{stepMeta?.label || 'Review step'}
|
|
</p>
|
|
<h4 className='mt-1 text-base font-semibold text-slate-900'>
|
|
{stepMeta?.description || 'Review details pending'}
|
|
</h4>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className={decisionBadge.className}>{decisionBadge.label}</span>
|
|
</div>
|
|
|
|
<div className='mt-5 grid gap-4 md:grid-cols-3'>
|
|
<DetailItem label='Assigned reviewer' value={formatPerson(step.assigned_reviewer)} />
|
|
<DetailItem label='Assigned at' value={formatDate(step.assigned_at)} />
|
|
<DetailItem label='Decided at' value={formatDate(step.decided_at)} />
|
|
</div>
|
|
|
|
{step.comments && (
|
|
<div className='mt-4 rounded-2xl border border-slate-200 bg-white px-4 py-4 text-sm leading-7 text-slate-600'>
|
|
{step.comments}
|
|
</div>
|
|
)}
|
|
|
|
{isActiveStep && canReviewStep && (
|
|
<div className='mt-5 rounded-2xl border border-blue-200 bg-white p-4'>
|
|
<div className='inline-flex items-center rounded-full border border-blue-200 bg-[#EEF4FF] px-3 py-1 text-sm font-semibold uppercase tracking-[0.16em] text-[#1D4ED8]'>
|
|
Reviewer action
|
|
</div>
|
|
<p className='mt-3 text-sm leading-7 text-slate-500'>Record a defensible decision and capture any guidance for the owner.</p>
|
|
<textarea
|
|
className='mt-4 min-h-[120px] w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200'
|
|
placeholder='Add comments for the owner, policy conditions, or controls required before approval.'
|
|
value={decisionComment}
|
|
onChange={(event) => setDecisionComment(event.target.value)}
|
|
/>
|
|
<div className='mt-4 flex flex-wrap gap-3'>
|
|
<BaseButton
|
|
color='success'
|
|
label={isWorking ? 'Saving…' : 'Approve'}
|
|
onClick={() => handleDecision('approved')}
|
|
disabled={isWorking}
|
|
/>
|
|
<BaseButton
|
|
color='warning'
|
|
label='Needs changes'
|
|
onClick={() => handleDecision('needs_changes')}
|
|
disabled={isWorking}
|
|
/>
|
|
<BaseButton
|
|
color='danger'
|
|
label='Reject'
|
|
onClick={() => handleDecision('rejected')}
|
|
disabled={isWorking}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isActiveStep && !canReviewStep && ACTIVE_REVIEW_STATUSES.has(useCase?.status || '') && (
|
|
<div className='mt-4 rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-4 text-sm text-slate-500'>
|
|
This is the live review step. A user with approval permissions can act here.
|
|
</div>
|
|
)}
|
|
|
|
{!isActiveStep && step.decision === 'pending' && (
|
|
<div className='mt-4 rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-4 text-sm text-slate-500'>
|
|
{ACTIVE_REVIEW_STATUSES.has(useCase?.status || '')
|
|
? 'Queued until the prior reviewer completes their decision.'
|
|
: 'Review sequence paused after an earlier decision on this use case.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className='rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-4 py-10 text-center text-sm text-slate-500'>
|
|
{timelineLoading || loading
|
|
? 'Loading approval steps…'
|
|
: 'No approval workflow yet. Submit the draft when you are ready to start review.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardBox>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
AiUseCasesView.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated permission='READ_AI_USE_CASES'>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default AiUseCasesView;
|