701 lines
32 KiB
TypeScript
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>
|
|
);
|
|
}
|