Files
Investidor-V2/resources/js/components/project-readiness.tsx
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

281 lines
13 KiB
TypeScript

import {
getProjectReadiness,
readPlannedRiskResponses,
serializePlannedRiskResponses,
type PlannedRiskResponse,
type ReadinessDraft,
} from '@/lib/project-readiness';
type Milestone = {
title: string;
due_date: string;
status: string;
planned_amount: string;
actual_amount: string;
};
function parseMilestones(value: string): Milestone[] {
try {
const parsed: unknown = JSON.parse(value || '[]');
return Array.isArray(parsed)
? parsed.filter((item): item is Milestone =>
Boolean(
item &&
typeof item === 'object' &&
typeof item.title === 'string' &&
typeof item.due_date === 'string' &&
typeof item.planned_amount === 'string',
),
)
: [];
} catch {
return [];
}
}
const kindLabels = {
missing: 'Informação em falta',
unverified: 'Hipótese por verificar',
risk: 'Risco identificado',
};
export function ProjectReadiness({
draft,
onChange,
onNavigate,
}: {
draft: ReadinessDraft;
onChange: (key: string, value: string) => void;
onNavigate: (fieldKey: string) => void;
}) {
const items = getProjectReadiness(draft);
const responses = readPlannedRiskResponses(draft);
const milestones = parseMilestones(
typeof draft.project_execution_snapshot === 'string'
? draft.project_execution_snapshot
: '',
);
const currency =
typeof draft.currency === 'string' && draft.currency
? draft.currency
: 'moeda ainda não indicada';
function updateResponse(
riskId: string,
patch: Partial<PlannedRiskResponse>,
) {
const current = responses.find(
(response) => response.risk_id === riskId,
) ?? {
risk_id: riskId,
action: '',
owner: '',
estimated_cost: '',
due_date: '',
};
const next = responses.filter(
(response) => response.risk_id !== riskId,
);
next.push({ ...current, ...patch });
onChange(
'project_risk_responses_snapshot',
serializePlannedRiskResponses(next),
);
}
return (
<section
className="space-y-4"
aria-labelledby="project-readiness-title"
>
<div>
<h4
id="project-readiness-title"
className="text-lg font-semibold"
>
O que precisa de esclarecer
</h4>
<p className="mt-1 text-sm text-slate-700">
Os avisos abaixo resultam apenas dos campos actualmente
preenchidos. Não atribuem probabilidades, perdas ou uma
classificação de viabilidade.
</p>
</div>
{items.length === 0 ? (
<p role="status" className="rounded border p-3 text-sm">
Não foram detectadas estas lacunas automáticas. Isto não
confirma a qualidade, exactidão ou viabilidade do projecto.
</p>
) : (
<ul className="space-y-3">
{items.map((item) => {
const response = responses.find(
(entry) => entry.risk_id === item.id,
) ?? {
risk_id: item.id,
action: '',
owner: '',
estimated_cost: '',
due_date: '',
};
return (
<li
key={item.id}
className="rounded-lg border border-slate-300 p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold tracking-wide uppercase">
{kindLabels[item.kind]}
</p>
<h5 className="mt-1 font-semibold">
{item.title}
</h5>
</div>
<button
type="button"
className="rounded border px-3 py-2 text-sm underline focus-visible:outline-2"
onClick={() =>
onNavigate(item.fieldKey)
}
>
Rever informação
</button>
</div>
<dl className="mt-3 grid gap-2 text-sm">
<div>
<dt className="font-medium">Origem</dt>
<dd>{item.origin}</dd>
</div>
<div>
<dt className="font-medium">
Parte afectada
</dt>
<dd>{item.affected}</dd>
</div>
<div>
<dt className="font-medium">
Possível consequência
</dt>
<dd>{item.consequence}</dd>
</div>
<div>
<dt className="font-medium">
Acção sugerida
</dt>
<dd>{item.nextAction}</dd>
</div>
</dl>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="grid gap-1 text-sm font-medium sm:col-span-2">
Resposta planeada
<textarea
className="min-h-20 rounded border p-2"
maxLength={500}
value={response.action}
onChange={(event) =>
updateResponse(item.id, {
action: event.target.value,
})
}
/>
</label>
{milestones.length > 0 && (
<label className="grid gap-1 text-sm font-medium sm:col-span-2">
Usar um marco já registado
<select
className="rounded border p-2"
defaultValue=""
onChange={(event) => {
const selected =
milestones[
Number(
event.target
.value,
)
];
if (selected)
updateResponse(
item.id,
{
action: selected.title,
due_date:
selected.due_date,
estimated_cost:
selected.planned_amount,
},
);
}}
>
<option value="">
Escolher marco para
preencher resposta, prazo e
custo
</option>
{milestones.map(
(milestone, index) => (
<option
key={`${milestone.title}-${index}`}
value={index}
>
{milestone.title ||
`Marco ${index + 1}`}
</option>
),
)}
</select>
</label>
)}
<label className="grid gap-1 text-sm font-medium">
Responsável (se definido)
<input
className="rounded border p-2"
maxLength={160}
value={response.owner}
onChange={(event) =>
updateResponse(item.id, {
owner: event.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm font-medium">
Custo previsto ({currency})
<input
type="number"
min="0"
step="0.01"
className="rounded border p-2"
value={response.estimated_cost}
onChange={(event) =>
updateResponse(item.id, {
estimated_cost:
event.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm font-medium">
Prazo previsto
<input
type="date"
className="rounded border p-2"
value={response.due_date}
onChange={(event) =>
updateResponse(item.id, {
due_date:
event.target.value,
})
}
/>
</label>
</div>
</li>
);
})}
</ul>
)}
</section>
);
}