Files
Investidor-V2/resources/js/components/opportunity-profile-actions.tsx
T
nala c13f250b87
tests / ci (push) Canceled after 0s
versao compilada
2026-10-15 17:37:00 +02:00

701 lines
32 KiB
TypeScript

import { router } from '@inertiajs/react';
import { useRef, useState } from 'react';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { button, control } from '@/pages/management/shared';
export default function OpportunityProfileActions({
id,
etag,
actions,
gates,
documents,
approvalHash,
onErrors,
participants = [],
publishers = [],
opinions = [],
publicationVisibility,
conditions = [],
conditionResponsibles = [],
reauthenticationRequired = false,
}: {
id: number;
etag: string;
actions: string[];
gates: string[];
documents: { id: string; title: string }[];
approvalHash?: string;
publicationVisibility?: string;
conditions?: {
id: string;
gate: string;
description: string;
responsible_name: string;
due_on: string | null;
blocking_action: string;
status: string;
}[];
conditionResponsibles?: { id: number; name: string }[];
onErrors?: (errors: Record<string, string>) => void;
reauthenticationRequired?: boolean;
publishers?: { id: number; name: string }[];
participants?: {
id: number;
name: string;
technical: boolean;
legal: boolean;
}[];
opinions?: {
id: string;
gate: string;
recipient_id: number;
status: string;
}[];
}) {
const [reason, setReason] = useState('');
const [items, setItems] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [messageIsError, setMessageIsError] = useState(false);
const feedback = useRef<HTMLDivElement>(null);
const [serverRequiresReauthentication, setServerRequiresReauthentication] =
useState(false);
const needsReauthentication =
reauthenticationRequired || serverRequiresReauthentication;
const [gate, setGate] = useState(gates[0] ?? '');
const [decision, setDecision] = useState('approved');
const [evidence, setEvidence] = useState<string[]>([]);
const [assignee, setAssignee] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [recipient, setRecipient] = useState('');
const [opinion, setOpinion] = useState('');
const [conditionDescription, setConditionDescription] = useState('');
const [conditionResponsible, setConditionResponsible] = useState('');
const [conditionDue, setConditionDue] = useState('');
const [conditionBlocking, setConditionBlocking] = useState('approve');
const [resolvedConditions, setResolvedConditions] = useState<string[]>([]);
const [actionErrors, setActionErrors] = useState<Record<string, string>>(
{},
);
const labels: Record<string, string> = {
submit: 'Submeter para validação',
approve: 'Aprovar versão',
publish:
publicationVisibility === 'public'
? 'Publicar no catálogo público'
: 'Disponibilizar com acesso restrito',
request_revision: 'Preparar alteração material',
review_gate: 'Guardar revisão especializada',
assign_review: 'Atribuir validação',
assign_publication: 'Atribuir publicação',
appeal_uphold: 'Manter rejeição após recurso',
appeal_reopen: 'Reabrir para correção',
request_archive: 'Solicitar arquivo',
appeal: 'Apresentar recurso',
start_review: 'Iniciar validação',
request_correction: 'Solicitar correções',
pending_opinion: 'Solicitar parecer',
respond_opinion: 'Responder ao parecer',
reject: 'Rejeitar com fundamento',
suspend: 'Suspender',
archive: 'Arquivar',
};
function focusFeedback() {
requestAnimationFrame(() => feedback.current?.focus());
}
function renewAuthentication() {
if (busy) return;
setBusy(true);
router.flushAll();
// Reuse normal logout and the protected URL's intended redirect, in this window.
router.post(
'/logout',
{},
{
preserveState: false,
onSuccess: () =>
router.visit(`/minhas-oportunidades/${id}/view`, {
preserveState: false,
}),
onError: () => {
setBusy(false);
setMessageIsError(true);
setMessage(
'Não foi possível renovar a entrada. Os dados guardados não foram alterados.',
);
focusFeedback();
},
},
);
}
async function send(action: string) {
if (busy) return;
if (needsReauthentication) {
setMessageIsError(true);
setMessage(
'Entre novamente nesta janela antes de executar a ação.',
);
focusFeedback();
return;
}
const correctionItems = items
.split('\n')
.map((v) => v.trim())
.filter(Boolean);
const inputErrors: Record<string, string> = {};
// Mirror the existing server requirements; incomplete drafts remain saveable.
const reasonLength = Array.from(reason.trim()).length;
if (reasonLength < 20 || reasonLength > 1000) {
inputErrors.reason =
'Preencha a fundamentação com 20 a 1000 caracteres antes de executar esta ação.';
}
if (action === 'request_correction' && correctionItems.length === 0) {
inputErrors.correction_items =
'Indique pelo menos um campo ou documento a corrigir.';
}
if (
action === 'request_correction' &&
correctionItems.some((item) => Array.from(item).length > 250)
) {
inputErrors.correction_items =
'Cada indicação de correção deve ter no máximo 250 caracteres.';
}
if (Object.keys(inputErrors).length > 0) {
setActionErrors(inputErrors);
onErrors?.(inputErrors);
setMessageIsError(true);
setMessage('A ação não foi enviada. Corrija os campos indicados.');
focusFeedback();
return;
}
setBusy(true);
setActionErrors({});
onErrors?.({});
setMessageIsError(false);
setMessage('A registar a ação…');
try {
const result = await profileJson(
`/minhas-oportunidades/${id}/workflow`,
{
etag,
action,
reason,
correction_items: correctionItems,
...(['review_gate', 'respond_opinion'].includes(action)
? {
gate,
gate_decision: decision,
evidence_document_ids: evidence,
}
: {}),
...(action === 'review_gate'
? {
resolve_condition_ids: resolvedConditions.filter(
(conditionId) =>
conditions.some(
(condition) =>
condition.id === conditionId &&
condition.gate === gate &&
condition.status === 'open',
),
),
...(conditionDescription.trim()
? {
condition: {
description:
conditionDescription.trim(),
responsible_user_id:
Number(conditionResponsible),
due_on: conditionDue || null,
blocking_action: conditionBlocking,
},
}
: {}),
}
: {}),
...(action === 'pending_opinion'
? { gate, recipient_user_id: Number(recipient) }
: {}),
...(action === 'respond_opinion'
? { opinion_id: opinion }
: {}),
...(['assign_review', 'assign_publication'].includes(action)
? { assignee_user_id: Number(assignee) }
: {}),
...(action === 'publish'
? {
confirm_publication: confirmed,
preview_hash: approvalHash,
}
: {}),
},
);
if (action === 'review_gate') setResolvedConditions([]);
setMessage(result.message);
router.visit(`/minhas-oportunidades/${id}/view`, {
preserveState: true,
preserveScroll: true,
onSuccess: focusFeedback,
});
} catch (error) {
if (error instanceof ProfileValidationError) {
setServerRequiresReauthentication(error.reauthenticate);
setActionErrors(error.fields);
onErrors?.(error.fields);
}
setMessageIsError(true);
setMessage(
error instanceof Error
? error.message
: 'Resposta indisponível. Confira o estado antes de repetir.',
);
focusFeedback();
} finally {
setBusy(false);
}
}
return (
<section
className="space-y-3 rounded border p-4"
aria-label="Workflow interno"
>
<h2 className="text-lg font-semibold">Workflow interno</h2>
{needsReauthentication && (
<div className="rounded border p-3" role="alert">
<p>
A confirmação MFA desta janela precisa de renovação para
validar ou publicar. Os dados já guardados permanecem
disponíveis.
</p>
<button
type="button"
className={button}
disabled={busy}
onClick={renewAuthentication}
>
Entrar novamente nesta janela
</button>
</div>
)}
{message && (
<div
ref={feedback}
tabIndex={-1}
role={messageIsError ? 'alert' : 'status'}
aria-live={messageIsError ? 'assertive' : 'polite'}
className="rounded border p-3 focus:outline-2 focus:outline-offset-2"
>
<p>{message}</p>
{messageIsError &&
Object.entries(actionErrors).map(([key, text]) => (
<p key={key}>
{key === 'reason'
? 'Fundamentação'
: key === 'correction_items'
? 'Campos ou documentos a corrigir'
: key}
: {text}
</p>
))}
</div>
)}
<p>
Guarde o contrato antes de submeter. Aprovação exige revisões
especializadas desta versão; publicação exige outro utilizador
autorizado. Uma decisão interna não substitui decisão externa.
</p>
<p>
Aprovação interna confirma a revisão desta versão.
Disponibilização restrita não aparece no catálogo público nem dá
acesso a todas as contas. A publicação pública exige âmbito
público e ação independente do Publicador; não representa uma
decisão externa.
</p>
{conditions.length > 0 && (
<section
aria-label="Condições de revisão"
id={`workflow-${id}-conditions`}
tabIndex={-1}
>
<h3>Condições e evidências</h3>
{conditions.map((c) => (
<p key={c.id}>
{c.description} —{' '}
{c.status === 'resolved'
? 'Verificada internamente'
: 'Pendente'}
; responsável: {c.responsible_name}; prazo:{' '}
{c.due_on ?? 'não definido'}; impede:{' '}
{c.blocking_action === 'approve'
? 'aprovação'
: 'publicação'}
. A verificação interna não constitui decisão
externa.
</p>
))}
</section>
)}
{!!actions.length && (
<>
<label className="block">
Fundamentação
<textarea
id={`workflow-${id}-reason`}
aria-invalid={!!actionErrors.reason}
aria-describedby={
actionErrors.reason
? `workflow-${id}-reason-error`
: undefined
}
className={control}
minLength={20}
maxLength={1000}
required
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<span className="block text-sm">
Explique o motivo desta ação: 20 a 1000 caracteres.
Texto atual: {Array.from(reason.trim()).length} caracteres.
</span>
{actionErrors.reason && (
<span
id={`workflow-${id}-reason-error`}
role="alert"
>
{actionErrors.reason}
</span>
)}
</label>
{actions.includes('request_correction') && (
<label className="block">
Campos ou documentos a corrigir, um por linha
<textarea
id={`workflow-${id}-correction_items`}
aria-invalid={!!actionErrors.correction_items}
aria-describedby={
actionErrors.correction_items
? `workflow-${id}-correction_items-error`
: undefined
}
className={control}
value={items}
onChange={(e) => setItems(e.target.value)}
/>
{actionErrors.correction_items && (
<span
id={`workflow-${id}-correction_items-error`}
role="alert"
>
{actionErrors.correction_items}
</span>
)}
</label>
)}
{(actions.includes('review_gate') ||
actions.includes('pending_opinion') ||
actions.includes('respond_opinion')) && (
<fieldset>
<legend>Revisão especializada</legend>
<label>
Revisão
<select
id={`workflow-${id}-gate`}
className={control}
value={gate}
onChange={(e) => setGate(e.target.value)}
>
{gates.map((g) => (
<option key={g} value={g}>
{g.replaceAll('_', ' ')}
</option>
))}
</select>
</label>
<label>
Decisão
<select
className={control}
value={decision}
onChange={(e) =>
setDecision(e.target.value)
}
>
<option value="approved">Aprovada</option>
<option value="rejected">Rejeitada</option>
</select>
</label>
{documents.map((d) => (
<label key={d.id} className="block">
<input
type="checkbox"
checked={evidence.includes(d.id)}
onChange={(e) =>
setEvidence(
e.target.checked
? [...evidence, d.id]
: evidence.filter(
(id) => id !== d.id,
),
)
}
/>{' '}
{d.title}
</label>
))}
</fieldset>
)}
{actions.includes('review_gate') && (
<fieldset className="space-y-2">
<legend>Condição explícita desta revisão</legend>
<p>
Opcional. Só bloqueia a ação indicada. Sem prazo
automático ou responsabilidade externa
atribuída.
</p>
<label className="block">
Descrição da condição
<textarea
className={control}
id={`workflow-${id}-condition.description`}
value={conditionDescription}
onChange={(e) =>
setConditionDescription(e.target.value)
}
aria-invalid={
!!actionErrors['condition.description']
}
aria-describedby={
actionErrors['condition.description']
? `workflow-${id}-condition.description-error`
: undefined
}
/>
{actionErrors['condition.description'] && (
<span
id={`workflow-${id}-condition.description-error`}
role="alert"
>
{actionErrors['condition.description']}
</span>
)}
</label>
<label className="block">
Responsável pela preparação da evidência
<select
className={control}
id={`workflow-${id}-condition.responsible_user_id`}
value={conditionResponsible}
onChange={(e) =>
setConditionResponsible(e.target.value)
}
aria-invalid={
!!actionErrors[
'condition.responsible_user_id'
]
}
aria-describedby={
actionErrors[
'condition.responsible_user_id'
]
? `workflow-${id}-condition.responsible_user_id-error`
: undefined
}
>
<option value="">Selecione</option>
{conditionResponsibles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{actionErrors[
'condition.responsible_user_id'
] && (
<span
id={`workflow-${id}-condition.responsible_user_id-error`}
role="alert"
>
{
actionErrors[
'condition.responsible_user_id'
]
}
</span>
)}
</label>
<label className="block">
Prazo específico da condição, se definido
<input
className={control}
type="date"
id={`workflow-${id}-condition.due_on`}
value={conditionDue}
onChange={(e) =>
setConditionDue(e.target.value)
}
aria-invalid={
!!actionErrors['condition.due_on']
}
/>
</label>
<label className="block">
Ação impedida até verificação
<select
className={control}
value={conditionBlocking}
onChange={(e) =>
setConditionBlocking(e.target.value)
}
>
<option value="approve">Aprovação</option>
<option value="publish">Publicação</option>
</select>
</label>
{conditions
.filter(
(c) =>
c.gate === gate && c.status === 'open',
)
.map((c) => (
<label key={c.id} className="block">
<input
type="checkbox"
checked={resolvedConditions.includes(
c.id,
)}
onChange={(e) =>
setResolvedConditions(
e.target.checked
? [
...resolvedConditions,
c.id,
]
: resolvedConditions.filter(
(id) =>
id !== c.id,
),
)
}
/>
Verifiquei com os documentos
selecionados: {c.description}
</label>
))}
</fieldset>
)}
{(actions.includes('assign_review') ||
actions.includes('assign_publication')) && (
<label className="block">
Responsável a designar
<select
className={control}
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
>
<option value="">Por atribuir</option>
{(actions.includes('assign_publication')
? publishers
: participants
).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<span>
O servidor verifica o papel, âmbito e
independência. Nenhum responsável é atribuído
automaticamente.
</span>
</label>
)}
{actions.includes('pending_opinion') && (
<label className="block">
Destinatário do parecer
<select
className={control}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
>
<option value="">Por designar</option>
{participants
.filter((p) =>
/legal|registry|authority|confidentiality|license/.test(
gate,
)
? p.legal
: p.technical,
)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)}
{actions.includes('respond_opinion') && (
<label className="block">
Parecer atribuído
<select
className={control}
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
>
<option value="">Selecione</option>
{opinions
.filter((o) => o.status === 'pending')
.map((o) => (
<option key={o.id} value={o.id}>
{o.gate.replaceAll('_', ' ')} —
conta {o.recipient_id}
</option>
))}
</select>
</label>
)}
{Object.entries(actionErrors)
.filter(([key]) => !key.startsWith('canonical'))
.map(([key, msg]) => (
<p key={key} role="alert">
{key.startsWith('conditions.')
? 'Condição de revisão'
: key}
: {msg}
</p>
))}
{actions.includes('publish') && (
<label className="block">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>{' '}
Confirmei a pré-visualização e o âmbito de
divulgação desta versão.
</label>
)}
<div className="flex flex-wrap gap-3">
{actions.map((action) => (
<button
type="button"
key={action}
className={button}
disabled={
busy ||
needsReauthentication ||
(action === 'publish' && !confirmed)
}
onClick={() => void send(action)}
>
{labels[action] ?? action}
</button>
))}
</div>
</>
)}
</section>
);
}