Files
Investidor-V2/resources/js/pages/management/opportunity-draft.tsx
T

1370 lines
62 KiB
TypeScript

import { inputText } from '../../lib/input-text.ts';
import {
ProactiveInvestmentEditor,
newProactive,
type ProactiveAnalysis,
type ProactiveOptions,
} from '@/components/proactive-investment';
import { ClassificationReview } from '@/components/opportunity-classification';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
import { modalityLabels } from '@/components/opportunity-search-filters';
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
import OpportunityTechnicalIncidents, {
type OpportunityTechnicalIncident,
} from '@/components/opportunity-technical-incidents';
import OpportunityContactForm from '@/components/opportunity-contact-form';
import OpportunityCanonicalFields, {
type CanonicalOptions,
} from '@/components/opportunity-canonical-fields';
import { Link, router, usePage } from '@inertiajs/react';
import TerritoryGeometryPreview from '@/components/territory-geometry-preview';
import { useState, useEffect } from 'react';
import {
TerritoryLevel,
type TerritoryOption,
} from '@/components/investment-territory';
import { Management, Field, button, control, states } from './shared';
type Values = Record<string, unknown>;
type Entry = {
id: number;
reference: string;
status: string;
deleted_at: string | null;
management_version: number;
};
export default function OpportunityDraft({
record,
proactiveOptions,
proactiveHistory,
values,
etag,
readOnly,
closedVersion,
classification,
sectors,
categories,
history,
territoryPath,
resourcesInstalled = false,
maturityCatalogue,
}: {
record: Entry | null;
proactiveOptions?: ProactiveOptions;
proactiveHistory?: {
version: number;
at: string;
state: string;
problem: string;
confirmation: string;
}[];
values: Values;
etag: string | null;
readOnly: boolean;
closedVersion: boolean;
classification: string;
sectors: { id: number; name: string }[];
categories: string[];
territoryPath: TerritoryOption[];
resourcesInstalled?: boolean;
maturityCatalogue: {
version: string;
stages: Record<string, string>;
legacyVersion: string;
legacyLabels: Record<string, string>;
};
history: {
id: string;
action: string;
actor_user_id: number;
occurred_at: string;
}[];
}) {
const {
basePath = '/management/opportunities',
profileMode = false,
profileOrganization,
profileContext,
profileAccess,
profileActions = [],
profileReauthenticationRequired = false,
canonicalOptions,
workflowState,
canonicalResponse,
workflowTasks = [],
workflowIncidents = [],
workflowParticipants,
publicationPreview,
publicationState,
promotionHref,
curationHref,
auth,
canEditDraft = false,
} = usePage<{
promotionHref?: string | null;
curationHref?: string | null;
basePath?: string;
profileMode?: boolean;
profileOrganization?: string | null;
profileActions?: string[];
workflowIncidents?: OpportunityTechnicalIncident[];
profileReauthenticationRequired?: boolean;
profileContext?: Record<string, unknown> | null;
profileAccess?: {
internalPromoter?: boolean;
contexts: {
organization_id: string | null;
organization_name?: string;
type: string;
}[];
} | null;
publicationPreview?: {
title: string;
summary: string;
description: string;
};
workflowParticipants?: {
condition_responsibles?: { id: number; name: string }[];
publishers: { id: number; name: string }[];
validators: {
id: number;
name: string;
technical: boolean;
legal: boolean;
}[];
};
canonicalOptions?: CanonicalOptions;
workflowState?: string;
canonicalResponse?: {
completion: {
percentage: number;
filled: number;
applicable: number;
missing_paths: string[];
};
materiality?: { reopened: string[]; retained: string[] } | null;
};
publicationState?: {
scope: string;
label: string;
public_catalog: boolean;
};
workflowTasks?: {
id: string;
title: string;
status: string;
assignee_user_id: number | null;
due_at: string | null;
}[];
auth: { user: { id: number } };
canEditDraft?: boolean;
}>().props;
const proactiveEntryUrl = usePage().url;
const uniquePromoterContext =
profileAccess?.contexts.length === 1
? profileAccess.contexts[0]
: undefined;
const [data, setData] = useState<Values>({
...values,
...(!record &&
!values.proactive &&
new URLSearchParams(proactiveEntryUrl.split('?')[1] ?? '').get(
'analysis',
) === 'proactive'
? { proactive: newProactive() }
: {}),
...(profileMode
? {
organization_id: record
? (profileOrganization ?? null)
: profileAccess?.internalPromoter
? null
: (uniquePromoterContext?.organization_id ?? null),
opportunity_category:
values.opportunity_category ??
uniquePromoterContext?.type ??
'',
}
: {}),
creation_key: createOperationKey(),
});
const [busy, setBusy] = useState(false),
[errors, setErrors] = useState<Record<string, string>>({}),
[message, setMessage] = useState(''),
[reason, setReason] = useState(''),
[confirm, setConfirm] = useState(false),
[token, setToken] = useState(etag);
useEffect(() => setToken(etag), [etag]);
const [savedContacts, setSavedContacts] = useState<
{ id: string; full_name: string }[]
>([]);
const [path, setPath] = useState(territoryPath);
const unavailable = Boolean(
record?.deleted_at || record?.status === 'archived',
);
const hasCanonicalPreparation =
profileMode &&
canonicalOptions &&
Boolean(data.opportunity_category) &&
!data.proactive;
const archiveAvailable = Boolean(usePage().props.opportunityManagement);
const set = (key: string, value: string) =>
setData((d) => ({ ...d, [key]: value }));
async function send(
action: 'save' | 'delete' | 'archive' | 'restore',
resourcesGroup?: string,
) {
if (busy) return;
setBusy(true);
setErrors({});
setMessage('');
try {
const cookie = document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='));
const url =
action === 'save'
? record
? `${basePath}/${record.id}/draft`
: `${basePath}/drafts`
: `${basePath}/${record!.id}/lifecycle`;
const response = await fetch(url, {
method: action === 'save' && record ? 'PUT' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': cookie
? decodeURIComponent(cookie.slice(11))
: '',
},
body: JSON.stringify(
action === 'save'
? { ...data, etag: token, reason }
: { etag: token, action, reason, confirm },
),
});
const result = await response.json();
if (!response.ok) {
setErrors(
Object.fromEntries(
Object.entries(result.errors ?? {}).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(' ') : valueText(v),
]),
),
);
setMessage(
result.message ??
'Operação falhou; os dados preenchidos foram preservados.',
);
return;
}
setMessage(result.message);
if (result.etag) setToken(result.etag);
if (resourcesGroup)
router.visit(
`${basePath}/${record?.id ?? result.id}/resources?return_group=${encodeURIComponent(resourcesGroup)}`,
);
else if (!record)
router.visit(
`${basePath}/${result.id}/edit${window.location.hash.startsWith('#fill-') ? window.location.hash : ''}`,
);
else if (action !== 'save') router.reload();
else if (profileMode)
router.reload({
only: [
'canonicalResponse',
'etag',
'history',
'workflowState',
'profileContext',
],
});
} catch {
setMessage(
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
);
} finally {
setBusy(false);
requestAnimationFrame(() => {
const invalidAssociation =
document.querySelector<HTMLSelectElement>(
'[data-association-index][aria-invalid="true"]',
);
if (invalidAssociation) invalidAssociation.focus();
else
document
.getElementById('opportunity-result')
?.focus({ preventScroll: true });
});
}
}
return (
<Management
title={
record
? `${readOnly ? 'Ver' : 'Editar'} oportunidade ${record.reference}`
: 'Nova oportunidade'
}
>
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
{curationHref && (
<Link className="ml-3 underline" href={curationHref}>
Curadoria de destaque
</Link>
)}
{promotionHref && (
<Link className="ml-3 underline" href={promotionHref}>
Divulgação promocional — Em preparação
</Link>
)}
{!!profileContext?.corridor_agribusiness && (
<section className="my-4 space-y-2 rounded border p-4">
<h2 className="font-semibold">
Origem e enquadramento da oportunidade
</h2>
<p>
{valueText(
(
profileContext.corridor_agribusiness as Record<
string,
unknown
>
).corridor,
)}
</p>
<p>
Fonte:{' '}
{valueText(
(
profileContext.corridor_agribusiness as Record<
string,
unknown
>
).source,
)}
</p>
<p>
Em preparação. O corredor indicado na fonte não confirma
implantação, promotor, viabilidade ou financiamento.
Complete a ficha e reveja a prévia antes de divulgar.
</p>
</section>
)}
{record && (
<Link
href={`/investimentos/iniciar?source=opportunity:${record.id}`}
className={button}
>
Preparar ou continuar investimento com esta ficha
</Link>
)}
{profileMode && (
<details
aria-label="Disponibilidade por modalidade"
className="rounded border p-3"
>
<summary className="cursor-pointer font-medium">
Modalidades disponíveis e condições de submissão
</summary>
<p>
Seis categorias base: projeto de investimento,
imobiliário, fundiário, venda de negócio, parceria e
franquia. Rascunho, correção, revisão independente e
publicação são ações distintas; uma aprovação interna
não atribui direitos nem substitui uma decisão externa.
</p>
<p>
No imobiliário, a submissão inclui venda e arrendamento
de imóvel concluído, desocupado e sem encargos ou
limitações urbanísticas adicionais. Pode guardar e
recuperar as outras variantes em preparação; a sua
submissão permanece indisponível.
</p>
<p>
Concessão, financiamento, fornecimento e outros,
partilha individual de anexos, restauração de
oportunidades e exportação normativa integral
Response/§27 não estão incluídos. O Response disponível
conserva o contrato de compatibilidade e o histórico.
</p>
<p>
As restantes variantes económicas, societárias,
territoriais, de contribuições e licenças exigem
documentação e revisão aplicáveis. Esta entrega não
certifica a viabilidade, a titularidade ou a decisão
externa de cada caso.
</p>
</details>
)}
{profileMode && record && (
<ClassificationReview id={record.id} refreshToken={token} />
)}
{profileMode && record && readOnly && canEditDraft && (
<Link
className="block underline"
href={`${basePath}/${record.id}/edit`}
>
Editar proposta privada
</Link>
)}
{profileMode && !record && (
<label className="block">
Promotor real e categoria autorizada
<select
aria-label="Promotor real e categoria autorizada"
className={control}
value={`${valueText(data.organization_id ?? '')}|${valueText(data.opportunity_category ?? '')}`}
onChange={(e) => {
const [organization, type] =
e.target.value.split('|');
setData((d) => ({
...d,
organization_id: organization || null,
opportunity_category: type,
}));
}}
>
{(profileAccess?.internalPromoter ||
!uniquePromoterContext) && (
<option value="|">
Selecionar o promotor real da oportunidade
</option>
)}
{(profileAccess?.contexts ?? []).map((c) => (
<option
key={`${c.organization_id}|${c.type}`}
value={`${c.organization_id ?? ''}|${c.type}`}
>
{c.organization_name ??
c.organization_id ??
'Âmbito individual'}{' '}
— {modalityLabels[c.type] ?? c.type}
</option>
))}
</select>
</label>
)}
{profileMode && record && (
<p
role="status"
aria-live="polite"
aria-label="Estado da oportunidade"
>
Estado da preparação:{' '}
{(workflowState ?? record.status) === 'published'
? 'Versão disponibilizada'
: ((
{
draft: 'Rascunho',
submitted: 'Submetida para validação',
needs_information: 'Correções solicitadas',
information_pending: 'Aguarda parecer',
in_review: 'Em validação interna',
approved: 'Aprovada internamente',
rejected: 'Rejeitada',
suspended: 'Suspensa',
archived: 'Arquivada',
} as Record<string, string>
)[workflowState ?? record.status] ??
states[workflowState ?? record.status] ??
workflowState ??
record.status)}
. {publicationState?.label}.{' '}
{profileContext?.review_cycle != null && (
<>
Ciclo de revisão interna:{' '}
{valueText(profileContext.review_cycle)}.{' '}
</>
)}
{profileContext?.request_archive
? 'Pedido de arquivo registado; aguarda decisão independente.'
: ''}{' '}
O Promotor solicita arquivo; não elimina nem restaura
oportunidades.
</p>
)}
<p>
Classificação de origem:{' '}
{(
{
real: 'Oportunidade real',
example: 'Exemplo demonstrativo',
undetermined: 'Origem ainda indeterminada',
} as Record<string, string>
)[classification] ?? classification}
. Esta classificação não cria associações territoriais.
</p>
{closedVersion && (
<p>
Versão anterior fechada. As alterações são guardadas numa
nova versão de rascunho; a ficha publicada permanece
intacta. Guardar não submete: use as ações do workflow para
revisão, aprovação e publicação independentes.
</p>
)}
{profileMode && record && canonicalResponse && (
<section
aria-label="Preenchimento e revisões"
aria-live="polite"
className="rounded border p-3"
>
<p>
Preenchimento gravado:{' '}
{canonicalResponse.completion.percentage}% (
{canonicalResponse.completion.filled}/
{canonicalResponse.completion.applicable} campos
aplicáveis). Não equivale a validação ou aprovação.
</p>
{canonicalResponse.materiality && (
<>
<p>
Revisões reabertas:{' '}
{canonicalResponse.materiality.reopened.join(
', ',
) || 'Nenhuma'}
.
</p>
<p>
Revisões conservadas:{' '}
{canonicalResponse.materiality.retained.join(
', ',
) || 'Nenhuma'}
.
</p>
<p>
A versão alterada exige nova aprovação
independente; as assinaturas dos pareceres
conservados permanecem no histórico.
</p>
</>
)}
<a href={`${basePath}/${record.id}/response`}>
Abrir resposta canónica gravada
</a>
</section>
)}
<div
id="opportunity-result"
tabIndex={-1}
role={Object.keys(errors).length ? 'alert' : 'status'}
className="rounded border p-3"
>
{message}
{errors.etag && (
<p>
{errors.etag}{' '}
<a
target="_blank"
rel="noreferrer"
href={`${basePath}/${record?.id}/view`}
>
Abrir versão atual
</a>
</p>
)}
</div>
<form
className="max-w-3xl space-y-4"
onSubmit={(e) => {
e.preventDefault();
void send('save');
}}
>
{profileMode && Boolean(data.proactive) && (
<fieldset
disabled={busy || readOnly || unavailable}
className="space-y-3 rounded border p-4"
>
<legend>Contexto existente da análise</legend>
<Field label="Título da análise">
<input
aria-label="Título da análise"
className={control}
value={inputText(
(
data.canonical as
| Record<string, unknown>
| undefined
)?.official_title ??
data.title ??
'',
)}
onChange={(e) => {
const title = e.target.value;
setData((d) => ({
...d,
title,
...(d.canonical &&
Object.prototype.hasOwnProperty.call(
d.canonical,
'official_title',
)
? {
canonical: {
...(d.canonical as Record<
string,
unknown
>),
official_title: title,
},
}
: {}),
}));
}}
/>
</Field>
<Field label="Sector da análise">
<select
aria-label="Sector da análise"
className={control}
value={inputText(data.sector_id ?? '')}
onChange={(e) =>
setData((d) => ({
...d,
sector_id: e.target.value,
...(d.canonical
? {
canonical: {
...(d.canonical as Record<
string,
unknown
>),
sector_id:
Number(
e.target.value,
) || null,
},
}
: {}),
}))
}
>
<option value="">Por determinar</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
<p>
Seleccione apenas o nível territorial conhecido; não
exige coordenadas, montante ou promotor.
</p>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`proactive-${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/minhas-oportunidades/territory-options"
fieldError={errors[key]}
onChange={(value) => {
const next = [
...path.slice(0, index),
...(value ? [value] : []),
];
setPath(next);
setData((d) => ({
...d,
province_id: next[0]?.id ?? '',
district_id: next[1]?.id ?? '',
admin_post_id: next[2]?.id ?? '',
locality_id: next[3]?.id ?? '',
}));
}}
/>
))}
</fieldset>
)}
<fieldset disabled={busy || readOnly || unavailable}>
<ProactiveInvestmentEditor
value={data.proactive as ProactiveAnalysis | undefined}
onChange={(v) =>
setData((d) => ({ ...d, proactive: v }))
}
options={proactiveOptions}
history={proactiveHistory}
territoryId={path.at(-1)?.id}
recordId={record?.id}
/>
</fieldset>
{!profileMode && (
<fieldset
disabled={busy || readOnly || unavailable}
className="space-y-4"
>
<section aria-label="Onde se localiza — referência principal">
<h2 className="font-semibold">
Onde se localiza — referência principal
</h2>
<p>
Seleccione o território conhecido. Endereço,
geometria e locais adicionais documentados podem
ser preparados em Localizações, reutilizando
esta hierarquia. Uma referência provincial não
determina distrito, coordenadas ou pertença a
uma associação.
</p>
<TerritoryGeometryPreview
territoryId={path.at(-1)?.id}
management
endpoint={
profileMode
? '/minhas-oportunidades/territory-geometries'
: undefined
}
/>
<p>
Hierarquia do cadastro territorial existente.
Alterar esta seleção não cria relações com
projetos/clusters.
</p>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint={
profileMode
? '/minhas-oportunidades/territory-options'
: '/management/territory-options'
}
fieldError={errors[key]}
onChange={(value) => {
const next = [
...path.slice(0, index),
...(value ? [value] : []),
];
setPath(next);
setData((previous) => ({
...previous,
province_id: next[0]?.id ?? '',
district_id: next[1]?.id ?? '',
admin_post_id: next[2]?.id ?? '',
locality_id: next[3]?.id ?? '',
}));
}}
/>
))}
</section>
<Field
label="Categoria"
error={errors.opportunity_category}
>
<select
className={control}
value={valueText(
data.opportunity_category ?? '',
)}
onChange={(e) =>
set('opportunity_category', e.target.value)
}
>
<option value="">Por indicar</option>
{categories.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
{Object.entries({
title: 'Título oficial',
summary: 'Resumo',
description: 'Descrição',
promoter_name: 'Promotor',
}).map(([key, label]) => (
<Field key={key} label={label} error={errors[key]}>
{key === 'description' || key === 'summary' ? (
<textarea
className={control}
value={valueText(data[key] ?? '')}
onChange={(e) =>
set(key, e.target.value)
}
/>
) : (
<input
className={control}
value={valueText(data[key] ?? '')}
onChange={(e) =>
set(key, e.target.value)
}
/>
)}
</Field>
))}
<Field label="Sector" error={errors.sector_id}>
<select
className={control}
value={valueText(data.sector_id ?? '')}
onChange={(e) =>
set('sector_id', e.target.value)
}
>
<option value="">Por indicar</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
<Field
label="Tipo de promotor"
error={errors.promoter_type}
>
<select
className={control}
value={valueText(data.promoter_type ?? '')}
onChange={(e) =>
set('promoter_type', e.target.value)
}
>
<option value="">Por indicar</option>
{Boolean(values.promoter_type) &&
![
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
].includes(
valueText(values.promoter_type),
) && (
<option
value={valueText(
values.promoter_type,
)}
>
{valueText(values.promoter_type)}{' '}
(valor existente)
</option>
)}
{[
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
].map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field
label="Etapa de maturidade"
error={
errors.maturity_stage ?? errors.maturity_level
}
>
<select
className={control}
value={
data.maturity_catalogue_version ===
maturityCatalogue.version
? valueText(data.maturity_stage ?? '')
: data.maturity_level
? `legacy:${valueText(data.maturity_level)}`
: ''
}
onChange={(e) => {
const stage = e.target.value;
if (stage.startsWith('legacy:')) {
setData((d) => ({
...d,
maturity_catalogue_version:
maturityCatalogue.legacyVersion,
maturity_level: Number(
stage.slice(7),
),
maturity_stage: null,
}));
} else {
setData((d) => ({
...d,
maturity_catalogue_version:
maturityCatalogue.version,
maturity_stage: stage || null,
maturity_level: stage
? Object.keys(
maturityCatalogue.stages,
).indexOf(stage) + 1
: null,
}));
}
}}
>
<option value="">Por indicar</option>
{Boolean(values.maturity_level) &&
values.maturity_catalogue_version !==
maturityCatalogue.version && (
<option
value={`legacy:${valueText(values.maturity_level)}`}
>
{
maturityCatalogue.legacyLabels[
valueText(
values.maturity_level,
)
]
}{' '}
(catálogo histórico; sem conversão)
</option>
)}
{Object.entries(maturityCatalogue.stages).map(
([code, label], index) => (
<option key={code} value={code}>
{index + 1}. {label}
</option>
),
)}
</select>
<p>
O catálogo de oito etapas é guardado com a sua
versão. Os valores históricos conservam o
significado anterior.
</p>
</Field>
<Field
label="Modalidade de financiamento existente"
error={errors.financing_modality}
>
<input
className={control}
value={valueText(data.financing_modality ?? '')}
onChange={(e) =>
set('financing_modality', e.target.value)
}
/>
</Field>
<Field
label="Impacto esperado"
error={errors.expected_impact}
>
<textarea
className={control}
value={valueText(data.expected_impact ?? '')}
onChange={(e) =>
set('expected_impact', e.target.value)
}
/>
</Field>
{(data.opportunity_category === 'investment_project' ||
(!data.opportunity_category &&
data.estimated_investment_amount != null)) && (
<>
<Field
label="Investimento estimado"
error={errors.estimated_investment_amount}
>
<input
className={control}
value={valueText(
data.estimated_investment_amount ??
'',
)}
onChange={(e) =>
set(
'estimated_investment_amount',
e.target.value,
)
}
/>
</Field>
<Field
label="Moeda"
error={errors.estimated_investment_currency}
>
<select
className={control}
value={valueText(
data.estimated_investment_currency ??
'',
)}
onChange={(e) =>
set(
'estimated_investment_currency',
e.target.value,
)
}
>
<option value="">Por indicar</option>
{['MZN', 'USD', 'EUR', 'ZAR'].map(
(c) => (
<option key={c}>{c}</option>
),
)}
</select>
</Field>
</>
)}
{Boolean(data.opportunity_category) &&
data.opportunity_category !==
'investment_project' && (
<p>
Guarde o rascunho incompleto e complete o
contrato aplicável abaixo. Submissão e
publicação exigem os documentos e as
revisões desta categoria.
</p>
)}
</fieldset>
)}
{!readOnly && (
<>
{!hasCanonicalPreparation && (
<Field
label="Motivo da alteração"
error={errors.reason}
>
<textarea
className={control}
value={reason}
onChange={(e) => setReason(e.target.value)}
minLength={20}
maxLength={1000}
/>
</Field>
)}
{!unavailable && !hasCanonicalPreparation && (
<button className={button} disabled={busy}>
Guardar rascunho
</button>
)}
{record && !profileMode && (
<>
<label className="block">
<input
type="checkbox"
checked={confirm}
onChange={(e) =>
setConfirm(e.target.checked)
}
/>{' '}
Confirmo a{' '}
{unavailable
? 'restauração'
: 'operação indicada, preservando o registo'}
, preservando histórico e relações
</label>
<button
type="button"
className={button}
disabled={busy || !confirm}
onClick={() =>
void send(
unavailable
? 'restore'
: archiveAvailable
? 'archive'
: 'delete',
)
}
>
{unavailable
? 'Restaurar'
: archiveAvailable || closedVersion
? 'Arquivar'
: 'Excluir rascunho'}
</button>
{archiveAvailable &&
!unavailable &&
!closedVersion && (
<button
type="button"
className={button}
disabled={busy || !confirm}
onClick={() => void send('delete')}
>
Excluir rascunho
</button>
)}
</>
)}
</>
)}
</form>
{record &&
(!profileMode ||
readOnly ||
unavailable ||
!canonicalOptions ||
!data.opportunity_category) && (
<Link
href={
resourcesInstalled
? `${basePath}/${record.id}/resources`
: '/management/opportunity-resources-install'
}
>
{resourcesInstalled
? 'Imagens, documentos e localizações'
: 'Plano de ativação de imagens, documentos e localizações'}
</Link>
)}
{record && !profileMode && (
<Link href={`${basePath}/${record.id}/context`}>
Associações territoriais e produtivas existentes
</Link>
)}
{profileMode &&
canonicalOptions &&
Boolean(data.opportunity_category) && (
<section>
{record && !readOnly && !unavailable && (
<details>
<summary className="cursor-pointer font-medium">
Acrescentar um contacto à oportunidade
</summary>
<OpportunityContactForm
id={record.id}
etag={token ?? ''}
organization={
(data.organization_id ?? null) as
| string
| null
}
investors={
canonicalOptions.contactInvestors ?? []
}
disabled={busy || !token}
onSaved={(contact) => {
setSavedContacts((prior) => [
...prior.filter(
(row) => row.id !== contact.id,
),
contact,
]);
setData((prior) => ({
...prior,
canonical: {
...(prior.canonical as Record<
string,
unknown
>),
internal_contact_id: contact.id,
},
}));
}}
/>
</details>
)}
<OpportunityCanonicalFields
value={
(data.canonical ?? {}) as Record<
string,
unknown
>
}
onChange={(v) =>
setData((d) => ({ ...d, canonical: v }))
}
options={{
...canonicalOptions,
contacts: [
...canonicalOptions.contacts.filter(
(row) =>
!savedContacts.some(
(saved) => saved.id === row.id,
),
),
...savedContacts,
],
}}
errors={errors}
missingPaths={
canonicalResponse?.completion.missing_paths
}
category={valueText(
data.opportunity_category ?? '',
)}
organization={
(data.organization_id ?? null) as string | null
}
actorId={auth.user.id}
sectors={sectors}
disabled={busy || readOnly || unavailable}
onOpenResources={
!readOnly && !unavailable && resourcesInstalled
? (group) => void send('save', group)
: undefined
}
/>
{!readOnly &&
!unavailable &&
hasCanonicalPreparation && (
<div className="bg-background sticky bottom-0 space-y-3 rounded border p-4 shadow-sm">
<Field
label="O que alterou nesta preparação"
error={errors.reason}
>
<textarea
className={control}
value={reason}
onChange={(event) =>
setReason(event.target.value)
}
minLength={20}
maxLength={1000}
/>
</Field>
<button
type="button"
className={button}
disabled={busy}
onClick={() => void send('save')}
>
Guardar preparação
</button>
<p className="text-sm">
Pode guardar incompleto e continuar mais
tarde em Minhas oportunidades.
</p>
</div>
)}
{Object.entries(errors)
.filter(([key]) => key.startsWith('canonical'))
.map(([key, msg]) => (
<p key={key} role="alert">
{key}: {msg}
</p>
))}
</section>
)}
{profileMode && record && profileActions.includes('publish') && (
<section className="rounded border p-3">
<h2>Pré-visualização da publicação</h2>
<p>
Âmbito solicitado:{' '}
{(data.canonical as Record<string, unknown> | undefined)
?.visibility === 'public'
? 'Catálogo público, após ação do Publicador'
: 'Acesso restrito aos perfis e à organização autorizados'}
</p>
<h3>{publicationPreview?.title}</h3>
<p>{publicationPreview?.summary}</p>
<p>{publicationPreview?.description}</p>
<p>
A ficha partilha apenas título, resumo, proposta, sector
e território. Contactos, avaliações, evidências e
originais conservam o acesso restrito.
</p>
</section>
)}
{profileMode && record && (
<OpportunityTechnicalIncidents incidents={workflowIncidents} />
)}
{profileMode && record && etag && (
<OpportunityProfileActions
reauthenticationRequired={profileReauthenticationRequired}
conditions={
Object.values(profileContext?.conditions ?? {}) as {
id: string;
gate: string;
description: string;
responsible_name: string;
due_on: string | null;
blocking_action: string;
status: string;
}[]
}
conditionResponsibles={
workflowParticipants?.condition_responsibles
}
participants={workflowParticipants?.validators}
publishers={workflowParticipants?.publishers}
opinions={Object.values(
(profileContext?.opinions ?? {}) as Record<
string,
{
id: string;
gate: string;
recipient_id: number;
status: string;
}
>,
)}
onErrors={setErrors}
id={record.id}
etag={token ?? etag}
actions={profileActions}
publicationVisibility={valueText(
(data.canonical as Record<string, unknown> | undefined)
?.visibility ?? 'internal',
)}
gates={canonicalOptions?.gates ?? []}
documents={(
canonicalOptions?.resources.documents ?? []
).map((d) => ({
id: d.id,
title: d.payload.title ?? d.file?.file_name ?? d.id,
}))}
approvalHash={
(
profileContext?.approval as
| { hash?: string }
| undefined
)?.hash
}
/>
)}
{profileMode && record && (
<section className="space-y-2 rounded border p-3">
<h2>
Acompanhamento interno —{' '}
{(workflowState ?? record.status) === 'published'
? 'Versão disponibilizada'
: (states[workflowState ?? record.status] ??
workflowState ??
record.status)}
</h2>
<p>
SLA: sem política/prazo aprovado; as tarefas podem ser
preparadas em paralelo.
</p>
{workflowTasks.map((t) => (
<p key={t.id}>
{t.title} — {t.status}; responsável:{' '}
{t.assignee_user_id ?? 'por atribuir'}; prazo:{' '}
{t.due_at ?? 'não definido'}
</p>
))}
</section>
)}
<section aria-label="Histórico de gestão">
<h2>Histórico</h2>
{history.length ? (
history.map((e) => (
<p key={e.id}>
{e.action} · conta {e.actor_user_id} ·{' '}
{e.occurred_at}
</p>
))
) : (
<p>Sem eventos deste incremento.</p>
)}
</section>
</Management>
);
}