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

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>
);
}