604 lines
25 KiB
TypeScript
604 lines
25 KiB
TypeScript
import { Link, router, useForm } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import { Management, Field, control, button } from '../management/shared';
|
|
import { capabilities, purposes, states } from './labels';
|
|
import { ProvinceSelect } from '@/components/investment-territory';
|
|
|
|
type Source = {
|
|
id: string;
|
|
title: string;
|
|
language: string;
|
|
status: string;
|
|
nature: string;
|
|
valid_until: string;
|
|
};
|
|
type Props = {
|
|
controls: { capability: string; suspended: boolean }[];
|
|
sources: Source[];
|
|
documents: { id: string; file_name: string }[];
|
|
runs: {
|
|
id: string;
|
|
capability: string;
|
|
review_status: string;
|
|
review_requested: boolean;
|
|
created_at: string;
|
|
}[];
|
|
today: string;
|
|
};
|
|
export default function Index({
|
|
controls,
|
|
sources,
|
|
documents,
|
|
runs,
|
|
today,
|
|
}: Props) {
|
|
const [language, setLanguage] = useState<'pt' | 'en'>('pt');
|
|
const t = (pt: string, en: string) => (language === 'pt' ? pt : en);
|
|
const query = useForm({
|
|
capability: 'assistant',
|
|
language: 'pt',
|
|
purpose: 'investment_information',
|
|
query: '',
|
|
source_id: '',
|
|
interest_id: '',
|
|
province: '',
|
|
maturity_level: '',
|
|
});
|
|
const upload = useForm<{ file: File | null; authorised: boolean }>({
|
|
file: null,
|
|
authorised: false,
|
|
});
|
|
const source = useForm({
|
|
document_id: '',
|
|
title: '',
|
|
language: 'pt',
|
|
nature: 'pending',
|
|
source_url: '',
|
|
source_owner: '',
|
|
evidence_reference: '',
|
|
source_date: today,
|
|
valid_until: '',
|
|
authorised: false,
|
|
});
|
|
const [actionError, setActionError] = useState('');
|
|
const post = (url: string, data: Record<string, string | boolean>) => {
|
|
setActionError('');
|
|
router.post(url, data, {
|
|
onError: (errors) =>
|
|
setActionError(Object.values(errors).join(' ')),
|
|
});
|
|
};
|
|
return (
|
|
<Management
|
|
title={t(
|
|
'Assistência, fontes e revisão',
|
|
'Assistance, sources and review',
|
|
)}
|
|
>
|
|
<Field label="Idioma / Language">
|
|
<select
|
|
className={control}
|
|
value={language}
|
|
onChange={(e) => setLanguage(e.target.value as 'pt' | 'en')}
|
|
>
|
|
<option value="pt">Português</option>
|
|
<option value="en">English</option>
|
|
</select>
|
|
</Field>
|
|
<p className="rounded border p-4">
|
|
{t(
|
|
'Ferramentas assistivas com fontes e regras. Não há modelo generativo activo. Os resultados não são decisões oficiais nem garantias de retorno ou aprovação.',
|
|
'Assistive tools using sources and rules. No generative model is active. Results are not official decisions or guarantees of return or approval.',
|
|
)}
|
|
</p>
|
|
<form
|
|
className="grid gap-4 md:grid-cols-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
query.transform((data) => ({
|
|
...data,
|
|
language,
|
|
purpose:
|
|
purposes[data.capability] ??
|
|
'investment_information',
|
|
}));
|
|
query.post('/management/ai/runs');
|
|
}}
|
|
>
|
|
<Field label={t('Capacidade', 'Capability')}>
|
|
<select
|
|
className={control}
|
|
value={query.data.capability}
|
|
onChange={(e) =>
|
|
query.setData('capability', e.target.value)
|
|
}
|
|
>
|
|
{controls.map((c) => (
|
|
<option
|
|
key={c.capability}
|
|
value={c.capability}
|
|
disabled={c.suspended}
|
|
>
|
|
{
|
|
capabilities[c.capability]?.[
|
|
language === 'pt' ? 0 : 1
|
|
]
|
|
}
|
|
{c.suspended
|
|
? t(' (suspensa)', ' (suspended)')
|
|
: ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
{['assistant', 'search'].includes(query.data.capability) && (
|
|
<Field
|
|
label={t(
|
|
'Pergunta ou termos de pesquisa',
|
|
'Question or search terms',
|
|
)}
|
|
>
|
|
<textarea
|
|
required
|
|
maxLength={1000}
|
|
minLength={3}
|
|
className={control}
|
|
value={query.data.query}
|
|
onChange={(e) =>
|
|
query.setData('query', e.target.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{['assistant', 'search', 'summary'].includes(
|
|
query.data.capability,
|
|
) && (
|
|
<Field label={t('Fonte autorizada', 'Authorised source')}>
|
|
<select
|
|
required={query.data.capability === 'summary'}
|
|
className={control}
|
|
value={query.data.source_id}
|
|
onChange={(e) =>
|
|
query.setData('source_id', e.target.value)
|
|
}
|
|
>
|
|
<option value="">
|
|
{t(
|
|
'Todas as fontes elegíveis',
|
|
'All eligible sources',
|
|
)}
|
|
</option>
|
|
{sources
|
|
.filter((s) => s.status === 'approved')
|
|
.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
)}
|
|
{query.data.capability === 'classify' && (
|
|
<Field
|
|
label={t(
|
|
'Número interno do pedido (requer consentimento específico)',
|
|
'Internal request number (specific consent required)',
|
|
)}
|
|
>
|
|
<input
|
|
required
|
|
type="number"
|
|
min={1}
|
|
className={control}
|
|
value={query.data.interest_id}
|
|
onChange={(e) =>
|
|
query.setData('interest_id', e.target.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{query.data.capability === 'recommend' && (
|
|
<>
|
|
<Field
|
|
label={t(
|
|
'Província declarada',
|
|
'Declared province',
|
|
)}
|
|
>
|
|
<ProvinceSelect
|
|
value={query.data.province}
|
|
onChange={(province) =>
|
|
query.setData('province', province)
|
|
}
|
|
className={control}
|
|
placeholder={t(
|
|
'Seleccione uma província',
|
|
'Select a province',
|
|
)}
|
|
label={t(
|
|
'Província declarada',
|
|
'Declared province',
|
|
)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t(
|
|
'Maturidade pretendida',
|
|
'Desired maturity',
|
|
)}
|
|
>
|
|
<select
|
|
className={control}
|
|
value={query.data.maturity_level}
|
|
onChange={(e) =>
|
|
query.setData(
|
|
'maturity_level',
|
|
e.target.value,
|
|
)
|
|
}
|
|
>
|
|
<option value="">
|
|
{t('Não indicada', 'Not specified')}
|
|
</option>
|
|
{[1, 2, 3, 4, 5, 6].map((n) => (
|
|
<option key={n}>{n}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
</>
|
|
)}
|
|
<div>
|
|
{Object.entries(query.errors).map(([k, v]) => (
|
|
<p role="alert" key={k}>
|
|
{v}
|
|
</p>
|
|
))}
|
|
<button
|
|
disabled={
|
|
query.processing ||
|
|
controls.find(
|
|
(c) => c.capability === query.data.capability,
|
|
)?.suspended
|
|
}
|
|
className={button}
|
|
>
|
|
{t('Consultar', 'Run assistance')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-semibold">
|
|
{t('Suspensão imediata', 'Immediate suspension')}
|
|
</h2>
|
|
{actionError && <p role="alert">{actionError}</p>}
|
|
<div className="flex flex-wrap gap-3">
|
|
{controls.map((c) => (
|
|
<button
|
|
className={button}
|
|
key={c.capability}
|
|
onClick={() =>
|
|
post(
|
|
`/management/ai/controls/${c.capability}`,
|
|
{
|
|
suspended: !c.suspended,
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{t(
|
|
c.suspended ? 'Retomar' : 'Suspender',
|
|
c.suspended ? 'Resume' : 'Suspend',
|
|
)}{' '}
|
|
·{' '}
|
|
{
|
|
capabilities[c.capability]?.[
|
|
language === 'pt' ? 0 : 1
|
|
]
|
|
}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-semibold">
|
|
{t(
|
|
'Resultados recentes e revisão humana',
|
|
'Recent results and human review',
|
|
)}
|
|
</h2>
|
|
{runs.length === 0 && (
|
|
<p>{t('Ainda sem consultas.', 'No runs yet.')}</p>
|
|
)}
|
|
{runs.map((r) => (
|
|
<p key={r.id}>
|
|
<Link
|
|
className="underline"
|
|
href={`/management/ai/runs/${r.id}`}
|
|
>
|
|
{
|
|
capabilities[r.capability]?.[
|
|
language === 'pt' ? 0 : 1
|
|
]
|
|
}{' '}
|
|
· {r.created_at}
|
|
</Link>{' '}
|
|
—{' '}
|
|
{language === 'pt'
|
|
? states[r.review_status]
|
|
: r.review_status}
|
|
{r.review_requested &&
|
|
t(' · revisão solicitada', ' · review requested')}
|
|
</p>
|
|
))}
|
|
</section>
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-semibold">
|
|
{t('Fontes documentais', 'Document sources')}
|
|
</h2>
|
|
<p>
|
|
{t(
|
|
'Apenas ficheiros de texto UTF-8 do cadastro documental privado, activos e não confidenciais. A autorização de uso deve ser confirmada por outra pessoa.',
|
|
'Only active, non-confidential UTF-8 text files from the private document registry. Another person must confirm permission to use the source.',
|
|
)}
|
|
</p>
|
|
{sources.map((s) => (
|
|
<article
|
|
className="space-y-2 rounded border p-3"
|
|
key={s.id}
|
|
>
|
|
<p>
|
|
{s.title} · {s.language.toUpperCase()} ·{' '}
|
|
{language === 'pt' ? states[s.status] : s.status} ·{' '}
|
|
{s.valid_until}
|
|
</p>
|
|
<p>{language === 'pt' ? states[s.nature] : s.nature}</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
className={button}
|
|
onClick={() =>
|
|
post(
|
|
`/management/ai/sources/${s.id}/review`,
|
|
{
|
|
status: 'approved',
|
|
expected_status: s.status,
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{t(
|
|
'Confirmar autorização',
|
|
'Confirm authorisation',
|
|
)}
|
|
</button>
|
|
<button
|
|
className={button}
|
|
onClick={() =>
|
|
post(
|
|
`/management/ai/sources/${s.id}/review`,
|
|
{
|
|
status: 'revoked',
|
|
expected_status: s.status,
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{t('Revogar', 'Revoke')}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</section>
|
|
<details className="rounded border p-4">
|
|
<summary>
|
|
{t(
|
|
'Adicionar documento de texto autorizado',
|
|
'Add an authorised text document',
|
|
)}
|
|
</summary>
|
|
<form
|
|
className="mt-4 space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
upload.post('/management/ai/documents', {
|
|
forceFormData: true,
|
|
onSuccess: () => upload.reset(),
|
|
});
|
|
}}
|
|
>
|
|
<Field
|
|
label={t(
|
|
'Ficheiro .txt UTF-8 (até 195 KiB)',
|
|
'UTF-8 .txt file (up to 195 KiB)',
|
|
)}
|
|
>
|
|
<input
|
|
required
|
|
type="file"
|
|
accept=".txt,text/plain"
|
|
className={control}
|
|
onChange={(e) =>
|
|
upload.setData(
|
|
'file',
|
|
e.target.files?.[0] ?? null,
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<label className="flex gap-2">
|
|
<input
|
|
required
|
|
type="checkbox"
|
|
checked={upload.data.authorised}
|
|
onChange={(e) =>
|
|
upload.setData('authorised', e.target.checked)
|
|
}
|
|
/>
|
|
{t(
|
|
'Tenho autorização e confirmei a ausência de dados confidenciais ou pessoais desnecessários.',
|
|
'I am authorised and have confirmed there is no confidential or unnecessary personal data.',
|
|
)}
|
|
</label>
|
|
{Object.entries(upload.errors).map(([key, value]) => (
|
|
<p role="alert" key={key}>
|
|
{value}
|
|
</p>
|
|
))}
|
|
<button className={button} disabled={upload.processing}>
|
|
{t('Guardar documento', 'Save document')}
|
|
</button>
|
|
</form>
|
|
</details>
|
|
<details className="rounded border p-4">
|
|
<summary>
|
|
{t(
|
|
'Registar fonte existente para revisão',
|
|
'Register an existing source for review',
|
|
)}
|
|
</summary>
|
|
<form
|
|
className="mt-4 grid gap-4 md:grid-cols-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
source.post('/management/ai/sources', {
|
|
onSuccess: () => source.reset(),
|
|
});
|
|
}}
|
|
>
|
|
<Field label={t('Documento', 'Document')}>
|
|
<select
|
|
required
|
|
className={control}
|
|
value={source.data.document_id}
|
|
onChange={(e) =>
|
|
source.setData('document_id', e.target.value)
|
|
}
|
|
>
|
|
<option value="">
|
|
{t('Seleccionar', 'Select')}
|
|
</option>
|
|
{documents.map((d) => (
|
|
<option key={d.id} value={d.id}>
|
|
{d.file_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
{(
|
|
[
|
|
'title',
|
|
'source_url',
|
|
'source_owner',
|
|
'evidence_reference',
|
|
'source_date',
|
|
'valid_until',
|
|
] as const
|
|
).map((key) => (
|
|
<Field
|
|
key={key}
|
|
label={
|
|
{
|
|
title: t('Título', 'Title'),
|
|
source_url: t(
|
|
'Fonte HTTPS',
|
|
'HTTPS source',
|
|
),
|
|
source_owner: t(
|
|
'Entidade proprietária',
|
|
'Source owner',
|
|
),
|
|
evidence_reference: t(
|
|
'Referência da evidência e autorização',
|
|
'Evidence and authorisation reference',
|
|
),
|
|
source_date: t(
|
|
'Data da fonte',
|
|
'Source date',
|
|
),
|
|
valid_until: t('Válida até', 'Valid until'),
|
|
}[key]
|
|
}
|
|
>
|
|
<input
|
|
required
|
|
type={
|
|
['source_date', 'valid_until'].includes(key)
|
|
? 'date'
|
|
: key === 'source_url'
|
|
? 'url'
|
|
: 'text'
|
|
}
|
|
className={control}
|
|
value={source.data[key]}
|
|
onChange={(e) =>
|
|
source.setData(key, e.target.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
))}
|
|
<Field label={t('Idioma da fonte', 'Source language')}>
|
|
<select
|
|
className={control}
|
|
value={source.data.language}
|
|
onChange={(e) =>
|
|
source.setData('language', e.target.value)
|
|
}
|
|
>
|
|
<option value="pt">Português</option>
|
|
<option value="en">English</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={t('Natureza declarada', 'Declared nature')}>
|
|
<select
|
|
className={control}
|
|
value={source.data.nature}
|
|
onChange={(e) =>
|
|
source.setData('nature', e.target.value)
|
|
}
|
|
>
|
|
{[
|
|
'pending',
|
|
'official',
|
|
'historical',
|
|
'illustrative',
|
|
].map((n) => (
|
|
<option key={n} value={n}>
|
|
{language === 'pt' ? states[n] : n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<label className="flex items-start gap-2">
|
|
<input
|
|
required
|
|
type="checkbox"
|
|
checked={source.data.authorised}
|
|
onChange={(e) =>
|
|
source.setData('authorised', e.target.checked)
|
|
}
|
|
/>
|
|
{t(
|
|
'Confirmo autorização para consulta interna e ausência de dados confidenciais ou pessoais desnecessários.',
|
|
'I confirm authorisation for internal use and the absence of confidential or unnecessary personal data.',
|
|
)}
|
|
</label>
|
|
<div>
|
|
{Object.entries(source.errors).map(([k, v]) => (
|
|
<p role="alert" key={k}>
|
|
{v}
|
|
</p>
|
|
))}
|
|
<button
|
|
disabled={
|
|
source.processing || documents.length === 0
|
|
}
|
|
className={button}
|
|
>
|
|
{t('Submeter fonte', 'Submit source')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
</Management>
|
|
);
|
|
}
|