1370 lines
62 KiB
TypeScript
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>
|
|
);
|
|
}
|