731 lines
30 KiB
TypeScript
731 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|