39950-vm/frontend/src/pages/ai_use_cases/ai_use_cases-view.tsx
Flatlogic Bot 97439eda85 2
2026-05-11 12:32:55 +00:00

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;