Files
Investidor-V2/resources/js/components/partnership-candidacies.tsx
T

731 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, usePage } from '@inertiajs/react';
import {
criterionValue,
useCandidacyEdit,
useCandidacyLeaveGuard,
type CandidacyEditor,
} from '@/lib/partnership-candidacy-edit';
import {
Field,
Errors,
control,
button,
labels,
type Interest,
type Catalogues,
type Partnership,
} from '@/pages/partnerships/shared';
export type CandidacyProps = {
record: Partnership;
owner: boolean;
canEdit: boolean;
acceptingInterests: boolean;
canStartInterest: boolean;
interests: Interest[];
organizations: Catalogues['organizations'];
};
type DraftReporter = (id: string, dirty: boolean) => void;
function useReport(editor: CandidacyEditor, id: string, report: DraftReporter) {
useEffect(() => {
report(id, editor.dirty);
}, [report, id, editor.dirty]);
useEffect(() => () => report(id, false), [report, id]);
}
const submissionLabels: Record<string, string> = {
organization_id: 'Participação',
proposal: 'Proposta curta',
capacity: 'Capacidade disponível',
availability: 'Disponibilidade',
contribution: 'Contribuição proposta',
conflicts: 'Conflitos de interesse declarados',
consent: 'Autorização de partilha',
};
function Comparison({
editor,
organizations,
}: {
editor: CandidacyEditor;
organizations: Catalogues['organizations'];
}) {
const label = (key: string) =>
key.startsWith('criterion:')
? (editor.criterionLabels[key.slice('criterion:'.length)] ??
'Critério já indisponível')
: (submissionLabels[key] ?? 'Motivo da acção');
const display = (key: string, value: string | undefined) =>
key.startsWith('criterion:')
? `${labels[criterionValue(value).state]} — ${criterionValue(value).evidence || 'Sem referência de evidência'}`
: key === 'organization_id'
? value
? (organizations.find(
(organization) => organization.id === value,
)?.name ?? 'Organização já indisponível')
: 'Sem organização indicada'
: key === 'consent'
? value === 'yes'
? 'Autorizada'
: 'Não autorizada'
: value || 'Não indicado';
return (
<div className="space-y-3 rounded border p-3">
<button
type="button"
className="underline"
disabled={editor.refreshing || editor.form.processing}
onClick={editor.refresh}
>
Comparar com os dados actuais da candidatura
</button>
{editor.problem && <p role="alert">{editor.problem}</p>}
{editor.invalid && (
<p role="alert">
A candidatura não está disponível para este formulário com o
acesso actual. A preparação foi conservada e a gravação está
suspensa.
</p>
)}
{!editor.editable && editor.dirty && (
<p role="status">
O estado ou acesso actual não permite novas alterações. A
preparação abaixo foi conservada em memória, sem nova
autorização.
</p>
)}
{editor.refreshing && (
<p role="status">A recuperar sem apagar a preparação.</p>
)}
{editor.stale && (
<>
<p role="alert">
A candidatura, a parceria ou os critérios foram
alterados. Reveja as declarações e os requisitos actuais
antes de continuar.
</p>
{editor.changed.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>
<th>Campo</th>
<th>Actualmente guardado</th>
<th>Sua preparação</th>
<th>Escolha</th>
</tr>
</thead>
<tbody>
{editor.changed.map((key) => (
<tr key={key}>
<td>{label(key)}</td>
<td className="whitespace-pre-wrap">
{display(
key,
editor.current[key],
)}
</td>
<td className="whitespace-pre-wrap">
{display(
key,
editor.values[key],
)}
</td>
<td>
{editor.conflicts.includes(
key,
) ? (
<>
<button
type="button"
onClick={() =>
editor.choose(
key,
'current',
)
}
>
Usar valor actual
</button>{' '}
<button
type="button"
onClick={() =>
editor.choose(
key,
'mine',
)
}
>
Manter minha
alteração
</button>
{editor.unresolved.includes(
key,
) && (
<p>
Escolha o valor
a conservar.
</p>
)}
</>
) : (
'Actualização compatível'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<button
type="button"
className="underline"
disabled={
editor.invalid ||
editor.refreshing ||
editor.form.processing ||
editor.unresolved.length > 0
}
onClick={editor.adopt}
>
Usar este contexto e conservar as alterações escolhidas
</button>
</>
)}
{editor.retired.length > 0 && (
<details>
<summary>
Critérios anteriores conservados apenas nesta preparação
</summary>
{editor.retired.map((key) => (
<p key={key}>
{label(key)}: {display(key, editor.values[key])}
</p>
))}
<p>
Estes critérios já não pertencem à avaliação actual e
não serão enviados.
</p>
<button
type="button"
disabled={editor.form.processing || editor.recovering}
onClick={editor.discardRetired}
>
Descartar preparação dos critérios anteriores
</button>
<p>
Esta acção só descarta a preparação local; não altera o
histórico guardado.
</p>
</details>
)}
{editor.recovering && (
<p role="status">
Recuperar repete o pedido original. As alterações escritas
depois do envio ficam para uma próxima operação.
</p>
)}
</div>
);
}
function Submission({
p,
recordId,
interestId = null,
actorId,
report,
onCreated,
suppressed = false,
}: {
p: CandidacyProps;
recordId: number;
interestId?: number | null;
actorId: number | null;
report: DraftReporter;
onCreated?: (id: number | null) => void;
suppressed?: boolean;
}) {
const editor = useCandidacyEdit({
props: p,
recordId,
interestId,
mode: 'submission',
actorId,
});
const draftId = 'submission:' + (interestId ?? 'new');
useReport(editor, draftId, report);
useEffect(() => {
onCreated?.(editor.activeId);
}, [onCreated, editor.activeId]);
const fresh = interestId === null;
const available = fresh
? p.interests.length === 0 || editor.activeId !== null
: true;
if (
(!available || suppressed || !editor.editable) &&
!editor.dirty &&
!editor.problem
)
return null;
const interest = p.interests.find(
(candidate) => candidate.id === (interestId ?? editor.activeId),
);
return (
<form
data-candidacy-form={draftId}
className="space-y-4 rounded border p-5"
onSubmit={(event) => {
event.preventDefault();
editor.submit();
}}
>
<h3 className="text-lg font-bold">
{editor.activeId === null
? 'Manifestar interesse'
: 'Actualizar manifestação de interesse'}
</h3>
<p>
Declarações são informações comunicadas por si; não atribuem
verificação. Dados desconhecidos podem ser esclarecidos no
processo.
</p>
{editor.activeId !== null && (
<p>
Voltar a submeter cria uma nova versão e reinicia a
avaliação actual. Esta operação conserva a versão anterior
no histórico de auditoria.
</p>
)}
<Comparison editor={editor} organizations={p.organizations} />
<fieldset
disabled={!editor.editable || editor.form.processing}
className="space-y-4"
>
<Field label="Participação">
<select
className={control}
value={editor.values.organization_id}
onChange={(event) =>
editor.change('organization_id', event.target.value)
}
>
<option value="">Sem organização indicada</option>
{editor.values.organization_id &&
!p.organizations.some(
(organization) =>
organization.id ===
editor.values.organization_id,
) && (
<option
value={editor.values.organization_id}
disabled
>
Organização já indisponível
</option>
)}
{p.organizations.map((organization) => (
<option
key={organization.id}
value={organization.id}
>
{organization.name}
</option>
))}
</select>
</Field>
{interest?.organization_id &&
!editor.values.organization_id && (
<p role="status">
Retirar a organização conserva o autor registado da
candidatura; a edição passa a depender desse autor,
conforme a regra de participação pessoal.
</p>
)}
<Field label="Proposta curta">
<textarea
className={control}
maxLength={15000}
value={editor.values.proposal}
onChange={(event) =>
editor.change('proposal', event.target.value)
}
/>
</Field>
{(
[
'capacity',
'availability',
'contribution',
'conflicts',
] as const
).map((key) => (
<Field key={key} label={submissionLabels[key]}>
<textarea
className={control}
maxLength={key === 'availability' ? 1000 : 5000}
value={editor.values[key]}
onChange={(event) =>
editor.change(key, event.target.value)
}
/>
</Field>
))}
<label className="block">
<input
type="checkbox"
checked={editor.values.consent === 'yes'}
onChange={(event) =>
editor.change(
'consent',
event.target.checked ? 'yes' : 'no',
)
}
/>{' '}
Autorizo a partilha desta candidatura com o responsável pela
parceria.
</label>
</fieldset>
<Errors errors={editor.form.errors} />
<button className={button} disabled={editor.blocked}>
{editor.recovering
? 'Recuperar submissão enviada'
: 'Guardar interesse'}
</button>
</form>
);
}
function Decision({
p,
recordId,
interestId,
actorId,
report,
}: {
p: CandidacyProps;
recordId: number;
interestId: number;
actorId: number | null;
report: DraftReporter;
}) {
const editor = useCandidacyEdit({
props: p,
recordId,
interestId,
mode: 'decision',
actorId,
});
useReport(editor, 'decision:' + interestId, report);
if (!editor.editable && !editor.dirty && !editor.problem) return null;
const unrecorded = editor.criteria.some(
(criterion) =>
editor.values['criterion:' + criterion.code] !==
editor.current['criterion:' + criterion.code],
);
return (
<section
data-candidacy-form={'decision:' + interestId}
className="space-y-4"
>
<Comparison editor={editor} organizations={p.organizations} />
<fieldset
disabled={!editor.editable || editor.form.processing}
className="space-y-4"
>
{editor.ownerMode && (
<details className="space-y-4">
<summary>Registar avaliação fundamentada</summary>
{editor.criteria.map((criterion) => {
const value = criterionValue(
editor.values['criterion:' + criterion.code],
);
return (
<div
key={criterion.code}
className="grid gap-3 md:grid-cols-2"
>
<Field label={criterion.label}>
<select
className={control}
value={value.state}
onChange={(event) =>
editor.changeCriterion(
criterion.code,
'state',
event.target.value,
)
}
>
{['unknown', 'met', 'unmet'].map(
(state) => (
<option
key={state}
value={state}
>
{labels[state]}
</option>
),
)}
</select>
</Field>
<Field
label={
'Referência da evidência — ' +
criterion.label
}
>
<input
className={control}
maxLength={2000}
value={value.evidence}
onChange={(event) =>
editor.changeCriterion(
criterion.code,
'evidence',
event.target.value,
)
}
/>
</Field>
</div>
);
})}
</details>
)}
<Field
label={
editor.ownerMode
? 'Motivo da decisão ou pedido de esclarecimento'
: 'Motivo da retirada'
}
>
<textarea
className={control}
maxLength={2000}
value={editor.values.reason}
onChange={(event) =>
editor.change('reason', event.target.value)
}
/>
</Field>
{editor.ownerMode && unrecorded && (
<p role="status">
Existem alterações de avaliação ainda por guardar. As
outras decisões usam a avaliação actualmente guardada,
apresentada acima.
</p>
)}
</fieldset>
<Errors errors={editor.form.errors} />
{editor.recovering ? (
<button
type="button"
className={button}
disabled={editor.blocked}
onClick={() => editor.submit()}
>
Recuperar acção enviada
</button>
) : (
<div className="flex flex-wrap gap-3">
{(editor.ownerMode
? [
'assess',
'clarify',
'shortlist',
'reject',
...(editor.status === 'shortlisted'
? ['connect']
: []),
]
: ['withdraw']
).map((action) => (
<button
type="button"
key={action}
className={button}
disabled={editor.blocked || !editor.values.reason}
onClick={() => editor.submit(action)}
>
{
{
assess: 'Guardar avaliação',
clarify: 'Pedir esclarecimento',
shortlist: 'Adicionar à lista curta',
reject: 'Não seleccionar',
connect: 'Abrir negociação',
withdraw: 'Retirar candidatura',
}[action]
}
</button>
))}
</div>
)}
</section>
);
}
function Card({
p,
id,
recordId,
actorId,
report,
suppressed,
}: {
p: CandidacyProps;
id: number;
recordId: number;
actorId: number | null;
report: DraftReporter;
suppressed: boolean;
}) {
const interest = p.interests.find((interest) => interest.id === id);
const score =
interest &&
interest.match.score_min !== null &&
interest.match.score_max !== null
? `${interest.match.score_min}–${interest.match.score_max}%`
: 'Por determinar';
return (
<article className="space-y-4 rounded border p-5" data-candidacy={id}>
{interest ? (
<>
<h3 className="text-lg font-bold">
Candidatura {interest.id} · {labels[interest.status]}
</h3>
<p>{interest.proposal}</p>
<Link
className="underline"
href={`/parcerias/candidaturas/${interest.id}/historico`}
>
Consultar versões e decisões
</Link>
<p>{interest.payload.capacity}</p>
<p>
Disponibilidade:{' '}
{interest.payload.availability || 'Por esclarecer'}
</p>
<p>
Contribuição:{' '}
{interest.payload.contribution || 'Por esclarecer'}
</p>
<p>
Conflitos declarados:{' '}
{interest.payload.conflicts || 'Sem informação'}
</p>
{interest.decision_reason && (
<p>Última decisão: {interest.decision_reason}</p>
)}
<p>
Correspondência: {score} · Elegibilidade:{' '}
{labels[interest.match.eligibility]}. O intervalo
distingue dados desconhecidos de incumprimentos; não é
uma aprovação.
</p>
<ul className="space-y-2">
{interest.match.criteria.map((criterion) => (
<li key={criterion.code}>
{criterion.label} · peso {criterion.weight} ·{' '}
{labels[criterion.state]}
{criterion.mandatory ? ' · Obrigatório' : ''}
{criterion.evidence && (
<> · Evidência: {criterion.evidence}</>
)}
<p className="text-sm">
{criterion.explanation}
</p>
</li>
))}
</ul>
{interest.room_id && (
<Link
className={button}
href={'/parcerias/salas/' + interest.room_id}
>
Abrir conexão privada
</Link>
)}
</>
) : (
<p role="alert">
Esta candidatura deixou de estar disponível para consulta
nesta conta. A preparação local foi conservada; não é uma
autorização de acesso.
</p>
)}
<Decision
p={p}
recordId={recordId}
interestId={id}
actorId={actorId}
report={report}
/>
<Submission
p={p}
recordId={recordId}
interestId={id}
actorId={actorId}
report={report}
suppressed={suppressed}
/>
</article>
);
}
export default function PartnershipCandidacies({
p,
otherDirty = false,
}: {
p: CandidacyProps;
otherDirty?: boolean;
}) {
const { auth } = usePage<{ auth: { user: { id: number } | null } }>().props;
const openedRecord = useRef(p.record.id);
const [drafts, setDrafts] = useState<Record<string, boolean>>({});
const [created, setCreated] = useState<number | null>(null);
const report = useCallback((id: string, dirty: boolean) => {
setDrafts((previous) =>
(previous[id] ?? false) === dirty
? previous
: { ...previous, [id]: dirty },
);
}, []);
const onCreated = useCallback((id: number | null) => setCreated(id), []);
const retained = Object.entries(drafts)
.filter(([key, dirty]) => dirty && !key.endsWith(':new'))
.map(([key]) => Number(key.split(':')[1]))
.filter(Number.isSafeInteger);
const ids = [
...new Set([
...p.interests.map((interest) => interest.id),
...retained,
]),
];
useCandidacyLeaveGuard(otherDirty || Object.values(drafts).some(Boolean));
return (
<section className="space-y-6" aria-label="Candidaturas e preparação">
{p.record.id !== openedRecord.current && (
<p role="alert">
A resposta não corresponde à parceria aberta. A preparação
foi conservada e os comandos estão suspensos.
</p>
)}
{ids.map((id) => (
<Card
key={id}
p={p}
id={id}
recordId={openedRecord.current}
actorId={auth.user?.id ?? null}
report={report}
suppressed={created === id}
/>
))}
<Submission
p={p}
recordId={openedRecord.current}
actorId={auth.user?.id ?? null}
report={report}
onCreated={onCreated}
/>
</section>
);
}