Files
Investidor-V2/resources/js/components/offer-operations.tsx
T

551 lines
26 KiB
TypeScript

import { Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { profileJson } from '@/lib/profile-json';
type Report = {
id: string;
version: number;
status: string;
reason: string;
resolution_reason: string | null;
source_record_version: number;
source_snapshot?: {
title?: string;
summary?: string;
description?: string;
} | null;
};
type Highlight = {
version: number;
status: string;
label: string;
note: string;
starts_at: string | null;
ends_at: string | null;
};
type Operation = {
id: number;
etag: string;
availability: {
state: string;
expires_at: string | null;
policy_pending: boolean;
};
editorial_state: string;
owner: boolean;
reviewer: boolean;
publisher: boolean;
reports: Report[];
highlight: Highlight | null;
history: { id: string; action: string; at: string; cycle: number }[];
};
const labels: Record<string, string> = {
available: 'Disponível',
withdrawn: 'Retirada',
expired: 'Expirada',
revalidation: 'Aguarda nova validação',
unavailable: 'Indisponível',
};
export default function OfferOperations({
id,
onChanged,
}: {
id: number;
onChanged: () => void;
}) {
const [value, setValue] = useState<Operation | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
const [confirm, setConfirm] = useState(false);
const [plan, setPlan] = useState({
label: '',
starts_at: '',
ends_at: '',
note: '',
status: 'draft',
});
const base = `/minhas-oportunidades/ofertas/${id}`;
useEffect(() => {
let active = true;
profileJson(`${base}/operacoes`, {}, 'GET')
.then((result) => {
if (active) {
setValue(result);
if (result.highlight)
setPlan({
label: result.highlight.label,
starts_at: result.highlight.starts_at ?? '',
ends_at: result.highlight.ends_at ?? '',
note: result.highlight.note,
status: result.highlight.status,
});
}
})
.catch((error) => {
if (active) setMessage(error.message);
});
return () => {
active = false;
};
}, [base]);
async function command(path: string, data: object) {
if (busy) return;
setBusy(true);
setMessage('');
try {
const result = await profileJson(`${base}/${path}`, data);
setValue(result);
setConfirm(false);
onChanged();
setMessage(
'Operação guardada em privado. A publicação permanece bloqueada.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível concluir.',
);
} finally {
setBusy(false);
}
}
async function refresh(loadPlan = false) {
setBusy(true);
try {
const next: Operation = await profileJson(
`${base}/operacoes`,
{},
'GET',
);
setValue((previous) =>
loadPlan
? next
: { ...next, highlight: previous?.highlight ?? null },
);
if (loadPlan)
setPlan({
label: next.highlight?.label ?? '',
starts_at: next.highlight?.starts_at ?? '',
ends_at: next.highlight?.ends_at ?? '',
note: next.highlight?.note ?? '',
status: next.highlight?.status ?? 'draft',
});
setConfirm(false);
onChanged();
setMessage(
loadPlan
? 'Plano guardado carregado para conferência.'
: 'Disponibilidade e denúncias actualizadas. As observações e o plano local foram preservados.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível actualizar.',
);
} finally {
setBusy(false);
}
}
function owner(action: string) {
if (value)
void command('operacoes', {
action,
etag: value.etag,
confirm: true,
reason,
});
}
return (
<section className="space-y-4 rounded-lg border p-4">
<h2 className="font-semibold">Disponibilidade e acompanhamento</h2>
<button
type="button"
className="underline"
disabled={busy}
onClick={() => void refresh()}
>
Actualizar disponibilidade e denúncias
</button>
{value && (
<>
<p>{labels[value.availability.state] ?? 'Indisponível'}</p>
<p className="text-sm">
{value.availability.policy_pending
? 'Prazo de validade por definir.'
: value.availability.expires_at
? `Validade até ${new Date(value.availability.expires_at).toLocaleString('pt-MZ')}.`
: 'A contagem de validade inicia-se com a publicação da versão.'}{' '}
O NDA tem controlo independente.
</p>
{value.owner && (
<div className="space-y-3">
<label className="block">
Observação (opcional)
<textarea
className="block w-full rounded border p-2"
maxLength={1000}
value={reason}
onChange={(event) =>
setReason(event.target.value)
}
/>
</label>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded border p-2"
disabled={
busy ||
value.availability.state === 'withdrawn'
}
onClick={() => owner('withdraw')}
>
Retirar disponibilidade agora
</button>
{['withdrawn', 'expired'].includes(
value.availability.state,
) && (
<button
type="button"
className="rounded border p-2"
disabled={busy}
onClick={() =>
owner('request_reactivation')
}
>
Pedir nova validação para reactivar
</button>
)}
{value.availability.state ===
'revalidation' && (
<button
type="button"
className="rounded border p-2"
disabled={
busy ||
value.editorial_state !== 'approved'
}
onClick={() => owner('reactivate')}
>
Repor disponibilidade após validação
</button>
)}
</div>
</div>
)}
{value.reviewer && (
<div className="space-y-3">
<h3 className="font-semibold">
Denúncias no meu âmbito
</h3>
<label className="block">
Fundamento do tratamento
<textarea
className="block w-full rounded border p-2"
minLength={20}
maxLength={2000}
value={reason}
onChange={(event) =>
setReason(event.target.value)
}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Confirmo a decisão para a versão consultada.
</label>
{value.reports.map((report) => (
<article
key={report.id}
className="space-y-2 rounded border p-3"
>
<p className="break-words whitespace-pre-line">
{report.reason}
</p>
{report.source_snapshot && (
<details>
<summary>
Conteúdo referido na denúncia
</summary>
<p className="font-semibold">
{report.source_snapshot.title}
</p>
<p className="break-words whitespace-pre-line">
{report.source_snapshot
.summary ??
report.source_snapshot
.description}
</p>
</details>
)}
<p className="text-sm">
Versão denunciada:{' '}
{report.source_record_version} ·{' '}
{
(
{
open: 'Por analisar',
in_review: 'Em análise',
resolved: 'Tratada',
} as Record<string, string>
)[report.status]
}
</p>
{report.resolution_reason && (
<p>{report.resolution_reason}</p>
)}
<div className="flex flex-wrap gap-3">
{(report.status === 'open'
? ['start_review']
: report.status === 'in_review'
? [
'resolve_no_withdrawal',
'resolve_withdrawal',
]
: []
).map((action) => (
<button
type="button"
className="underline"
key={action}
disabled={
busy ||
!confirm ||
reason.trim().length < 20
}
onClick={() =>
void command(
`denuncias/${report.id}`,
{
action,
etag: value.etag,
version:
report.version,
confirm,
reason,
},
)
}
>
{
(
{
start_review:
'Iniciar análise',
resolve_no_withdrawal:
'Concluir sem retirada',
resolve_withdrawal:
'Concluir e retirar disponibilidade',
} as Record<
string,
string
>
)[action]
}
</button>
))}
</div>
</article>
))}
</div>
)}
{value.publisher && (
<details className="space-y-3">
<summary>
Preparação administrativa de destaque
</summary>
<button
type="button"
className="underline"
disabled={busy}
onClick={() => void refresh(true)}
>
Carregar plano guardado (substitui os campos
locais)
</button>
<p>
Registo interno, sem cobrança, pagamento ou
promoção comercial activa.
</p>
<fieldset
disabled={busy}
className="grid gap-3 sm:grid-cols-2"
>
<label>
Designação
<input
className="block w-full rounded border p-2"
maxLength={150}
value={plan.label}
onChange={(event) =>
setPlan({
...plan,
label: event.target.value,
})
}
/>
</label>
<label>
Estado
<select
className="block w-full rounded border p-2"
value={plan.status}
onChange={(event) =>
setPlan({
...plan,
status: event.target.value,
})
}
>
<option value="draft">
Rascunho administrativo
</option>
<option value="archived">
Arquivado
</option>
</select>
</label>
<label>
Início previsto (UTC)
<input
className="block w-full rounded border p-2"
type="datetime-local"
value={plan.starts_at.replace(/Z$/, '')}
onChange={(event) =>
setPlan({
...plan,
starts_at: event.target.value
? `${event.target.value}Z`
: '',
})
}
/>
</label>
<label>
Fim previsto (UTC)
<input
className="block w-full rounded border p-2"
type="datetime-local"
value={plan.ends_at.replace(/Z$/, '')}
onChange={(event) =>
setPlan({
...plan,
ends_at: event.target.value
? `${event.target.value}Z`
: '',
})
}
/>
</label>
<label className="sm:col-span-2">
Fundamento
<textarea
className="block w-full rounded border p-2"
minLength={20}
maxLength={1000}
value={plan.note}
onChange={(event) =>
setPlan({
...plan,
note: event.target.value,
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Conferi a preparação administrativa.
</label>
<button
type="button"
className="rounded border p-2"
disabled={
!confirm ||
!plan.label ||
plan.note.trim().length < 20
}
onClick={() =>
void command(
'destaque-administrativo',
{
...plan,
starts_at:
plan.starts_at || null,
ends_at: plan.ends_at || null,
etag: value.etag,
version:
value.highlight?.version ??
0,
confirm,
},
)
}
>
Guardar preparação
</button>
</fieldset>
</details>
)}
<Link
className="inline-block underline"
href={`${base}/denunciar`}
>
Registar denúncia fundamentada
</Link>
<details>
<summary>Histórico de disponibilidade</summary>
<ul className="mt-2 space-y-2">
{value.history
.filter((event) =>
[
'offer_withdrawn',
'offer_reactivation_requested',
'offer_reactivated_privately',
'offer_expired',
].includes(event.action),
)
.map((event) => (
<li key={event.id}>
{
(
{
offer_withdrawn:
'Disponibilidade retirada',
offer_reactivation_requested:
'Nova validação pedida',
offer_reactivated_privately:
'Disponibilidade reposta',
offer_expired:
'Validade terminada',
} as Record<string, string>
)[event.action]
}{' '}
·{' '}
{new Date(event.at).toLocaleString(
'pt-MZ',
)}
</li>
))}
</ul>
</details>
</>
)}
<p role="status">{message}</p>
</section>
);
}