551 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|