285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { Link, router } from '@inertiajs/react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { createOperationKey } from '@/lib/operation-key';
|
|
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
|
import { Management, button, control } from './shared';
|
|
import type { Paginated } from './shared';
|
|
|
|
type Preview = {
|
|
hash: string;
|
|
title: string;
|
|
before: boolean;
|
|
featured: boolean;
|
|
reason: string;
|
|
};
|
|
|
|
export default function OpportunityCuration({
|
|
record,
|
|
etag: initialEtag,
|
|
history,
|
|
basePath,
|
|
}: {
|
|
record: { id: number; title: string; featured: boolean };
|
|
etag: string;
|
|
history: Paginated<{
|
|
id: number;
|
|
actor: number;
|
|
at: string;
|
|
featured: boolean;
|
|
reason: string;
|
|
}>;
|
|
basePath: string;
|
|
}) {
|
|
const [featured, setFeatured] = useState(!record.featured);
|
|
const [currentFeatured, setCurrentFeatured] = useState(record.featured);
|
|
const [reason, setReason] = useState('');
|
|
const [etag, setEtag] = useState(initialEtag);
|
|
const [key, setKey] = useState(createOperationKey);
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const inFlight = useRef(false);
|
|
const epoch = useRef(0);
|
|
const seenRecord = useRef(record.id);
|
|
const feedback = useRef<HTMLDivElement>(null);
|
|
const endpoint = `${basePath}/${record.id}/destaque`;
|
|
|
|
useEffect(() => {
|
|
if (seenRecord.current !== record.id) {
|
|
setReason('');
|
|
setMessage(
|
|
'Foi aberta outra oportunidade; prepare uma decisão própria.',
|
|
);
|
|
} else if (etag !== initialEtag) {
|
|
setMessage(
|
|
'A versão observada mudou; o fundamento foi conservado e precisa de nova prévia.',
|
|
);
|
|
}
|
|
seenRecord.current = record.id;
|
|
epoch.current++;
|
|
inFlight.current = false;
|
|
setBusy(false);
|
|
setEtag(initialEtag);
|
|
setFeatured(!record.featured);
|
|
setCurrentFeatured(record.featured);
|
|
setPreview(null);
|
|
setConfirmed(false);
|
|
setKey(createOperationKey());
|
|
}, [record.id, initialEtag, record.featured]);
|
|
|
|
function invalidate() {
|
|
setPreview(null);
|
|
setConfirmed(false);
|
|
setKey(createOperationKey());
|
|
}
|
|
|
|
async function send(confirm: boolean) {
|
|
if (inFlight.current || (confirm && (!preview || !confirmed))) return;
|
|
inFlight.current = true;
|
|
setBusy(true);
|
|
setErrors({});
|
|
setMessage('');
|
|
const currentEpoch = epoch.current;
|
|
try {
|
|
const result = await profileJson(
|
|
`${endpoint}/${confirm ? 'confirmar' : 'previa'}`,
|
|
{
|
|
etag,
|
|
client_key: key,
|
|
featured,
|
|
reason,
|
|
...(confirm
|
|
? { hash: preview?.hash, confirm: confirmed }
|
|
: {}),
|
|
},
|
|
);
|
|
if (epoch.current !== currentEpoch) return;
|
|
if (confirm) {
|
|
setPreview(null);
|
|
setConfirmed(false);
|
|
setEtag(result.etag);
|
|
setCurrentFeatured(result.featured);
|
|
setFeatured(!result.featured);
|
|
setKey(createOperationKey());
|
|
setReason('');
|
|
setMessage(
|
|
result.replayed
|
|
? 'Decisão já registada. O estado actual é recuperado.'
|
|
: 'Decisão de destaque registada.',
|
|
);
|
|
router.reload({
|
|
only: ['record', 'etag', 'history'],
|
|
});
|
|
} else {
|
|
setPreview(result);
|
|
setConfirmed(false);
|
|
}
|
|
} catch (error) {
|
|
if (epoch.current !== currentEpoch) return;
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Não foi possível concluir. Repita sem editar para recuperar a decisão.',
|
|
);
|
|
if (error instanceof ProfileValidationError)
|
|
setErrors(error.fields);
|
|
} finally {
|
|
if (epoch.current === currentEpoch) {
|
|
inFlight.current = false;
|
|
setBusy(false);
|
|
feedback.current?.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Management title="Curadoria de destaque">
|
|
<h1 className="text-2xl font-bold">{record.title}</h1>
|
|
<p>
|
|
Estado actual:{' '}
|
|
{currentFeatured ? 'Em destaque' : 'Sem destaque'}. A curadoria
|
|
não publica uma oportunidade nem aprova o investimento.
|
|
</p>
|
|
<div ref={feedback} tabIndex={-1} role="status" aria-live="polite">
|
|
{message}
|
|
</div>
|
|
<fieldset disabled={busy} className="space-y-4 rounded border p-4">
|
|
<legend>Decisão editorial individual</legend>
|
|
<label className="block">
|
|
Escolha
|
|
<select
|
|
className={control}
|
|
value={featured ? 'yes' : 'no'}
|
|
onChange={(event) => {
|
|
setFeatured(event.target.value === 'yes');
|
|
invalidate();
|
|
}}
|
|
>
|
|
<option value="yes">Colocar em destaque</option>
|
|
<option value="no">Retirar do destaque</option>
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
Fundamento
|
|
<textarea
|
|
className={control}
|
|
value={reason}
|
|
minLength={20}
|
|
maxLength={1000}
|
|
onChange={(event) => {
|
|
setReason(event.target.value);
|
|
invalidate();
|
|
}}
|
|
/>
|
|
</label>
|
|
{Object.entries(errors).map(([field, value]) => (
|
|
<p key={field} role="alert">
|
|
{value}
|
|
</p>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => send(false)}
|
|
>
|
|
Conferir prévia
|
|
</button>
|
|
{preview && (
|
|
<section
|
|
className="space-y-3 rounded border p-4"
|
|
aria-label="Prévia da decisão editorial"
|
|
>
|
|
<h2 className="font-bold">{preview.title}</h2>
|
|
<p>
|
|
{preview.before ? 'Em destaque' : 'Sem destaque'} →{' '}
|
|
{preview.featured ? 'Em destaque' : 'Sem destaque'}
|
|
</p>
|
|
<p className="whitespace-pre-wrap">{preview.reason}</p>
|
|
<label className="block">
|
|
<input
|
|
type="checkbox"
|
|
checked={confirmed}
|
|
onChange={(event) =>
|
|
setConfirmed(event.target.checked)
|
|
}
|
|
/>{' '}
|
|
Confirmo esta decisão e o fundamento apresentado.
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
disabled={!confirmed}
|
|
onClick={() => send(true)}
|
|
>
|
|
Confirmar decisão de destaque
|
|
</button>
|
|
</section>
|
|
)}
|
|
<p>
|
|
Se a resposta se perder, repita a confirmação sem editar os
|
|
campos.
|
|
</p>
|
|
</fieldset>
|
|
{!busy && (
|
|
<Link
|
|
href={`${basePath}/${record.id}/edit`}
|
|
className="underline"
|
|
>
|
|
Voltar à mesma oportunidade
|
|
</Link>
|
|
)}
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-bold">
|
|
Histórico de decisões de destaque — página{' '}
|
|
{history.current_page}
|
|
</h2>
|
|
{history.data.length ? (
|
|
history.data.map((item) => (
|
|
<article key={item.id} className="rounded border p-3">
|
|
<p>
|
|
{item.at} · Responsável {item.actor} ·{' '}
|
|
{item.featured
|
|
? 'Em destaque'
|
|
: 'Destaque retirado'}
|
|
</p>
|
|
<p className="whitespace-pre-wrap">{item.reason}</p>
|
|
</article>
|
|
))
|
|
) : (
|
|
<p>
|
|
Ainda sem decisões de curadoria registadas neste
|
|
percurso.
|
|
</p>
|
|
)}
|
|
{!busy && (
|
|
<nav
|
|
aria-label="Páginas do histórico de curadoria"
|
|
className="flex gap-4"
|
|
>
|
|
{history.prev_page_url && (
|
|
<Link
|
|
href={history.prev_page_url}
|
|
preserveState
|
|
preserveScroll
|
|
>
|
|
Anterior
|
|
</Link>
|
|
)}
|
|
{history.next_page_url && (
|
|
<Link
|
|
href={history.next_page_url}
|
|
preserveState
|
|
preserveScroll
|
|
>
|
|
Seguinte
|
|
</Link>
|
|
)}
|
|
</nav>
|
|
)}
|
|
</section>
|
|
</Management>
|
|
);
|
|
}
|