440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { buildOfferPackage } from '@/lib/iva-offer-package';
|
|
import { useRef, useState } from 'react';
|
|
import {
|
|
emptyOfferRequest,
|
|
offerRequestLabels,
|
|
type OfferPreparation,
|
|
} from '@/lib/iva-offer-preparations';
|
|
import {
|
|
emptyOfferWorkflow,
|
|
offerWrittenRequest,
|
|
offerWorkflowSource,
|
|
offerWorkflowApplied,
|
|
recordOfferWorkflow,
|
|
offerAttachmentValid,
|
|
type OfferAttachment,
|
|
type OfferWorkflow,
|
|
} from '@/lib/iva-offer-workflow';
|
|
export function SimulationOfferWorkflow({
|
|
row,
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
row: OfferPreparation;
|
|
draft: Record<string, unknown>;
|
|
onChange: (patch: Partial<OfferPreparation>) => void;
|
|
}) {
|
|
const stageLabels: Record<string, string> = {
|
|
request: 'Pedido em preparação',
|
|
presented: 'Pedido apresentado',
|
|
decision: 'Decisão recebida',
|
|
form: 'Formulário preparado',
|
|
};
|
|
const latest = useRef(row);
|
|
latest.current = row;
|
|
const [notice, setNotice] = useState('');
|
|
const [error, setError] = useState(''),
|
|
[busy, setBusy] = useState(false),
|
|
[documents, setDocuments] = useState<OfferAttachment[]>([]),
|
|
[draftId, setDraftId] = useState('');
|
|
const w = row.workflow ?? emptyOfferWorkflow();
|
|
const edit = (patch: Partial<OfferWorkflow>) =>
|
|
onChange({
|
|
workflow: {
|
|
...(latest.current.workflow ?? emptyOfferWorkflow()),
|
|
...patch,
|
|
},
|
|
});
|
|
const action = async (fn: () => void | Promise<void>) => {
|
|
setBusy(true);
|
|
try {
|
|
await fn();
|
|
setError('');
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
const current = async () => {
|
|
const response = await fetch('/api/v1/simulation-drafts/current', {
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok)
|
|
throw Error('Não foi possível localizar a gravação privada.');
|
|
const data = (await response.json()).data;
|
|
if (!data?.id)
|
|
throw Error(
|
|
'Guarde primeiro o rascunho no portal para lhe associar anexos privados.',
|
|
);
|
|
if (
|
|
data.economic_snapshot?.context_opportunity !==
|
|
draft.context_opportunity
|
|
)
|
|
throw Error(
|
|
'A gravação actual pertence a outro contexto;guarde este rascunho antes de anexar.',
|
|
);
|
|
setDraftId(data.id);
|
|
return data.id as string;
|
|
};
|
|
const list = async () => {
|
|
const id = await current(),
|
|
response = await fetch(
|
|
`/api/v1/simulation-drafts/${id}/offer-documents`,
|
|
{
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
},
|
|
);
|
|
if (response.status === 401 || response.status === 403)
|
|
throw Error(
|
|
'Autentique-se para anexar ou consultar documentos privados;os dados preparados mantêm-se.',
|
|
);
|
|
if (!response.ok)
|
|
throw Error('Não foi possível recuperar anexos privados.');
|
|
setDocuments((await response.json()).data);
|
|
};
|
|
const attach = async (
|
|
file: File,
|
|
field: 'requestAttachment' | 'decisionAttachment' | 'formAttachment',
|
|
) => {
|
|
const id = await current(),
|
|
data = new FormData();
|
|
data.append('file', file);
|
|
const csrf = document.querySelector<HTMLMetaElement>(
|
|
'meta[name="csrf-token"]',
|
|
)?.content;
|
|
const xsrf = document.cookie
|
|
.split('; ')
|
|
.find((c) => c.startsWith('XSRF-TOKEN='))
|
|
?.slice(11);
|
|
const response = await fetch(
|
|
`/api/v1/simulation-drafts/${id}/offer-documents`,
|
|
{
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
|
...(xsrf
|
|
? { 'X-XSRF-TOKEN': decodeURIComponent(xsrf) }
|
|
: {}),
|
|
},
|
|
body: data,
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw Error(
|
|
response.status === 401
|
|
? 'Autentique-se para guardar anexos privados.'
|
|
: 'Anexo não confirmado;rascunho preservado. ' +
|
|
(await response.text()).slice(0, 250),
|
|
);
|
|
const doc = (await response.json()).data;
|
|
if (!offerAttachmentValid(doc))
|
|
throw Error('Confirmação do anexo inválida.');
|
|
edit({ [field]: doc });
|
|
setDocuments((previous) => [
|
|
...previous.filter((d) => d.id !== doc.id),
|
|
doc,
|
|
]);
|
|
};
|
|
const download = (blob: Blob, name: string) => {
|
|
const url = URL.createObjectURL(blob),
|
|
a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
};
|
|
const packageForm = async () => {
|
|
const result = await buildOfferPackage(latest.current, async (url) => {
|
|
const response = await fetch(url, { credentials: 'same-origin' });
|
|
if (!response.ok)
|
|
throw Error(
|
|
'Anexo conservado;aguarde análise antimalware concluída antes de descarregar o conjunto.',
|
|
);
|
|
return response.arrayBuffer();
|
|
});
|
|
download(
|
|
new Blob([Uint8Array.from(result.bytes).buffer], {
|
|
type: 'application/zip',
|
|
}),
|
|
`${row.branch}-pedido-decisao-formulario.zip`,
|
|
);
|
|
onChange({ workflow: result.row.workflow });
|
|
setNotice(
|
|
'Conjunto preparado com pedido,elementos do formulário e despacho original conferido porSHA256;descarga iniciada.Submissão à autoridade permanece externa.',
|
|
);
|
|
};
|
|
const text = (
|
|
field:
|
|
| 'recipient'
|
|
| 'writtenRequest'
|
|
| 'decisionIssuer'
|
|
| 'formName'
|
|
| 'formSource'
|
|
| 'formContent'
|
|
| 'submissionReference',
|
|
label: string,
|
|
) => (
|
|
<label className="block" key={field}>
|
|
{label}
|
|
<textarea
|
|
className="block w-full rounded border p-2"
|
|
value={w[field]}
|
|
onChange={(e) => edit({ [field]: e.target.value })}
|
|
/>
|
|
</label>
|
|
);
|
|
const attachment = (
|
|
field: 'requestAttachment' | 'decisionAttachment' | 'formAttachment',
|
|
label: string,
|
|
) => (
|
|
<fieldset>
|
|
<legend>{label}</legend>
|
|
<label>
|
|
Reutilizar anexo privado
|
|
<select
|
|
value={w[field]?.id ?? ''}
|
|
onChange={(e) =>
|
|
edit({
|
|
[field]: documents.find(
|
|
(d) => d.id === e.target.value,
|
|
),
|
|
})
|
|
}
|
|
>
|
|
<option value="">Escolher documento existente</option>
|
|
{documents.map((d) => (
|
|
<option key={d.id} value={d.id}>
|
|
{d.name} — {d.scan}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Juntar ficheiro privado
|
|
<input
|
|
type="file"
|
|
accept="application/pdf,text/plain,image/png,image/jpeg"
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) void action(() => attach(file, field));
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</label>
|
|
{w[field] && (
|
|
<p>
|
|
{w[field]!.name};SHA256:{w[field]!.sha256};estado da análise
|
|
na associação:{w[field]!.scan}. Original privado
|
|
conservado;nenhuma publicação automática.
|
|
</p>
|
|
)}
|
|
</fieldset>
|
|
);
|
|
return (
|
|
<fieldset className="rounded border p-3">
|
|
<legend>Pedido → decisão → formulário com despacho</legend>
|
|
<p>{offerWorkflowSource}</p>
|
|
<p>
|
|
A submissão ao Ministério das Finanças/AT ocorre fora do
|
|
InvesteMoz. Pode preparar e guardar informação incompleta.
|
|
Guardar anexos privados reutiliza a autenticação e a análise
|
|
antimalware existentes;PDF,texto,PNG/JPEG até10MB é o limite
|
|
técnico de transferência,não condição do benefício.
|
|
</p>
|
|
{error && <p role="alert">{error}</p>}
|
|
{notice && <p role="status">{notice}</p>}
|
|
{!row.request && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({ request: emptyOfferRequest(), workflow: w })
|
|
}
|
|
>
|
|
Iniciar acompanhamento do pedido
|
|
</button>
|
|
)}
|
|
{text('recipient', 'Destinatário do pedido escrito')}
|
|
{text(
|
|
'writtenRequest',
|
|
'Justificação e elementos adicionais do pedido',
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
download(
|
|
new Blob([offerWrittenRequest(row, w)], {
|
|
type: 'text/plain;charset=utf-8',
|
|
}),
|
|
`${row.branch}-pedido.txt`,
|
|
)
|
|
}
|
|
>
|
|
Preparar pedido escrito com os dados existentes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void action(list)}
|
|
>
|
|
Recuperar anexos privados desta gravação
|
|
</button>
|
|
{draftId && <p>Anexos associados à gravação privada actual.</p>}
|
|
{attachment(
|
|
'requestAttachment',
|
|
'Pedido escrito e comprovativo de apresentação,quando disponíveis',
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void action(() =>
|
|
onChange({
|
|
workflow: recordOfferWorkflow(
|
|
latest.current,
|
|
'request',
|
|
).workflow,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
Registar preparação do pedido
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void action(() =>
|
|
onChange({
|
|
workflow: recordOfferWorkflow(
|
|
latest.current,
|
|
'presented',
|
|
).workflow,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
Registar pedido apresentado fora do portal
|
|
</button>
|
|
{text('decisionIssuer', 'Entidade emitente do despacho recebido')}
|
|
{attachment(
|
|
'decisionAttachment',
|
|
'Despacho recebido — original anexo',
|
|
)}
|
|
<label>
|
|
Esta linha está abrangida pelos bens,operação,período e
|
|
condições do despacho
|
|
<select
|
|
value={w.scopeConfirmed}
|
|
onChange={(e) =>
|
|
edit({
|
|
scopeConfirmed: e.target
|
|
.value as OfferWorkflow['scopeConfirmed'],
|
|
scopeKey: undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">Por conferir</option>
|
|
<option value="yes">
|
|
Sim,apenas esta linha e âmbito indicado
|
|
</option>
|
|
<option value="no">Não</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void action(() =>
|
|
onChange({
|
|
workflow: recordOfferWorkflow(
|
|
latest.current,
|
|
'decision',
|
|
).workflow,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
Registar decisão recebida e âmbito desta linha
|
|
</button>
|
|
<p role="status">{offerWorkflowApplied(row).reason}</p>
|
|
{text('formName', 'Formulário aplicável:designação/versão')}
|
|
{text(
|
|
'formSource',
|
|
'Referência que demonstra a aplicabilidade deste formulário',
|
|
)}
|
|
{text(
|
|
'formContent',
|
|
'Elementos para o formulário,com despacho de isenção anexo',
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
edit({
|
|
formContent:
|
|
w.formContent ||
|
|
`${offerWrittenRequest(row, w)}\nDespacho:${row.request?.decisionRef ?? ''};${row.request?.decisionOn ?? ''};emitente:${w.decisionIssuer}\nÂmbito:${row.request?.goods ?? ''};${row.request?.operationScope || row.request?.operation || ''};${row.request?.periodScope || `${row.request?.periodFrom ?? ''} a ${row.request?.periodTo ?? ''}`};${row.request?.conditions ?? ''}\nAnexo:${w.decisionAttachment?.name ?? ''};SHA256:${w.decisionAttachment?.sha256 ?? ''}`,
|
|
})
|
|
}
|
|
>
|
|
Reutilizar dados e despacho nos campos vazios do formulário
|
|
</button>
|
|
{attachment(
|
|
'formAttachment',
|
|
'Exemplar do formulário preenchido,quando disponível',
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void action(() =>
|
|
onChange({
|
|
workflow: recordOfferWorkflow(
|
|
latest.current,
|
|
'form',
|
|
).workflow,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
Preparar formulário com despacho associado
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void action(packageForm)}
|
|
>
|
|
Descarregar conjunto do pedido,formulário e despacho
|
|
</button>
|
|
{text(
|
|
'submissionReference',
|
|
'Referência da apresentação externa do formulário,se já ocorreu',
|
|
)}
|
|
<p>
|
|
O ModeloI.1 não é seleccionado automaticamente para estes ramos.
|
|
O conteúdo preparado só substitui o preenchimento do exemplar
|
|
quando houver correspondência demonstrada;uma referência em
|
|
falta não apaga a decisão recebida.
|
|
</p>
|
|
{w.records.map((record, i) => (
|
|
<p key={i}>
|
|
{record.at} — {stageLabels[record.stage] ?? record.stage} —{' '}
|
|
{offerRequestLabels[record.request.state]};pedido:
|
|
{record.request.reference};despacho:
|
|
{record.request.decisionRef};bens:{record.request.goods}
|
|
;anexo:
|
|
{record.workflow.decisionAttachment?.name ||
|
|
'não registado'}
|
|
.
|
|
</p>
|
|
))}
|
|
</fieldset>
|
|
);
|
|
}
|